diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 36717b4858e5..000000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,17 +0,0 @@ - -**Affects:** \ - ---- - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..8d92ceeb6f96 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +blank_issues_enabled: false +contact_links: + - name: Community Support + url: https://stackoverflow.com/tags/spring + about: Please ask and answer questions on StackOverflow with the tag `spring`. + diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 000000000000..08396dcc717e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,23 @@ +--- +name: General +about: Bugs, enhancements, documentation, tasks. +title: '' +labels: '' +assignees: '' + +--- + + + diff --git a/.github/actions/create-github-release/action.yml b/.github/actions/create-github-release/action.yml index a33b0b9b43da..03452537adf8 100644 --- a/.github/actions/create-github-release/action.yml +++ b/.github/actions/create-github-release/action.yml @@ -4,6 +4,10 @@ inputs: milestone: description: 'Name of the GitHub milestone for which a release will be created' required: true + pre-release: + description: 'Whether the release is a pre-release (a milestone or release candidate)' + required: false + default: 'false' token: description: 'Token to use for authentication with GitHub' required: true @@ -20,4 +24,4 @@ runs: shell: bash env: GITHUB_TOKEN: ${{ inputs.token }} - run: gh release create ${{ format('v{0}', inputs.milestone) }} --notes-file changelog.md + run: gh release create ${{ format('v{0}', inputs.milestone) }} --notes-file changelog.md ${{ inputs.pre-release == 'true' && '--prerelease' || '' }} diff --git a/.github/actions/prepare-gradle-build/action.yml b/.github/actions/prepare-gradle-build/action.yml index fdc9eb93bf0b..9b305a81790c 100644 --- a/.github/actions/prepare-gradle-build/action.yml +++ b/.github/actions/prepare-gradle-build/action.yml @@ -31,10 +31,11 @@ runs: ${{ inputs.java-early-access == 'true' && format('{0}-ea', inputs.java-version) || inputs.java-version }} ${{ inputs.java-toolchain == 'true' && '17' || '' }} - name: Set Up Gradle - uses: gradle/actions/setup-gradle@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0 + uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2 with: cache-read-only: false develocity-access-key: ${{ inputs.develocity-access-key }} + develocity-token-expiry: 4 - name: Configure Gradle Properties shell: bash run: | diff --git a/.github/actions/sync-to-maven-central/action.yml b/.github/actions/sync-to-maven-central/action.yml index 2332ba866e1f..22fa7fe87f01 100644 --- a/.github/actions/sync-to-maven-central/action.yml +++ b/.github/actions/sync-to-maven-central/action.yml @@ -20,7 +20,7 @@ runs: using: composite steps: - name: Set Up JFrog CLI - uses: jfrog/setup-jfrog-cli@9fe0f98bd45b19e6e931d457f4e98f8f84461fb5 # v4.4.1 + uses: jfrog/setup-jfrog-cli@f748a0599171a192a2668afee8d0497f7c1069df # v4.5.6 env: JF_ENV_SPRING: ${{ inputs.jfrog-cli-config-token }} - name: Download Release Artifacts diff --git a/.github/workflows/build-and-deploy-snapshot.yml b/.github/workflows/build-and-deploy-snapshot.yml index 0ce1d3afe64f..75dc9e857ada 100644 --- a/.github/workflows/build-and-deploy-snapshot.yml +++ b/.github/workflows/build-and-deploy-snapshot.yml @@ -2,7 +2,7 @@ name: Build and Deploy Snapshot on: push: branches: - - 6.1.x + - 6.2.x concurrency: group: ${{ github.workflow }}-${{ github.ref }} jobs: @@ -21,13 +21,13 @@ jobs: develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }} publish: true - name: Deploy - uses: spring-io/artifactory-deploy-action@26bbe925a75f4f863e1e529e85be2d0093cac116 # v0.0.1 + uses: spring-io/artifactory-deploy-action@dc1913008c0599f0c4b1fdafb6ff3c502b3565ea # v0.0.2 with: artifact-properties: | /**/framework-api-*.zip::zip.name=spring-framework,zip.deployed=false /**/framework-api-*-docs.zip::zip.type=docs /**/framework-api-*-schema.zip::zip.type=schema - build-name: 'spring-framework-6.1.x' + build-name: 'spring-framework-6.2.x' folder: 'deployment-repository' password: ${{ secrets.ARTIFACTORY_PASSWORD }} repository: 'libs-snapshot-local' diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index c386514fbda9..325268f1dd1a 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -9,22 +9,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - name: Set Up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: 'liberica' - java-version: '17' - - name: Check Out + - name: Check Out Code uses: actions/checkout@v4 - - name: Validate Gradle Wrapper - uses: gradle/actions/wrapper-validation@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0 - - name: Set Up Gradle - uses: gradle/actions/setup-gradle@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0 - name: Build - env: - CI: 'true' - GRADLE_ENTERPRISE_URL: 'https://ge.spring.io' - run: ./gradlew -Dorg.gradle.internal.launcher.welcomeMessageEnabled=false --no-daemon --no-parallel --continue build + id: build + uses: ./.github/actions/build - name: Print JVM Thread Dumps When Cancelled if: cancelled() uses: ./.github/actions/print-jvm-thread-dumps diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80f3ae5e479b..fc9a83255f68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: branches: - - 6.1.x + - 6.2.x concurrency: group: ${{ github.workflow }}-${{ github.ref }} jobs: @@ -21,8 +21,6 @@ jobs: toolchain: false - version: 21 toolchain: true - - version: 22 - toolchain: true - version: 23 toolchain: true exclude: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6aba59fa7374..641253927c14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Release on: push: tags: - - v6.1.[0-9]+ + - v6.2.[0-9]+ concurrency: group: ${{ github.workflow }}-${{ github.ref }} jobs: @@ -20,7 +20,7 @@ jobs: develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }} publish: true - name: Stage Release - uses: spring-io/artifactory-deploy-action@26bbe925a75f4f863e1e529e85be2d0093cac116 # v0.0.1 + uses: spring-io/artifactory-deploy-action@dc1913008c0599f0c4b1fdafb6ff3c502b3565ea # v0.0.2 with: artifact-properties: | /**/framework-api-*.zip::zip.name=spring-framework,zip.deployed=false @@ -73,7 +73,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up JFrog CLI - uses: jfrog/setup-jfrog-cli@9fe0f98bd45b19e6e931d457f4e98f8f84461fb5 # v4.4.1 + uses: jfrog/setup-jfrog-cli@dff217c085c17666e8849ebdbf29c8fe5e3995e6 # v4.5.2 env: JF_ENV_SPRING: ${{ secrets.JF_ARTIFACTORY_SPRING }} - name: Promote build diff --git a/.github/workflows/update-antora-ui-spring.yml b/.github/workflows/update-antora-ui-spring.yml new file mode 100644 index 000000000000..b1bc0e7b9cb3 --- /dev/null +++ b/.github/workflows/update-antora-ui-spring.yml @@ -0,0 +1,37 @@ +name: Update Antora UI Spring + +on: + schedule: + - cron: '0 10 * * *' # Once per day at 10am UTC + workflow_dispatch: + +permissions: + pull-requests: write + issues: write + contents: write + +jobs: + update-antora-ui-spring: + name: Update on Supported Branches + if: ${{ github.repository == 'spring-projects/spring-framework' }} + runs-on: ubuntu-latest + strategy: + matrix: + branch: [ '6.1.x' ] + steps: + - uses: spring-io/spring-doc-actions/update-antora-spring-ui@5a57bcc6a0da2a1474136cf29571b277850432bc + name: Update + with: + docs-branch: ${{ matrix.branch }} + token: ${{ secrets.GITHUB_TOKEN }} + antora-file-path: 'framework-docs/antora-playbook.yml' + update-antora-ui-spring-docs-build: + name: Update on docs-build + if: ${{ github.repository == 'spring-projects/spring-framework' }} + runs-on: ubuntu-latest + steps: + - uses: spring-io/spring-doc-actions/update-antora-spring-ui@5a57bcc6a0da2a1474136cf29571b277850432bc + name: Update + with: + docs-branch: 'docs-build' + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 3252c345c290..3ffb330e72ed 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -46,7 +46,7 @@ jobs: distribution: 'liberica' java-version: 17 - name: Set Up Gradle - uses: gradle/actions/setup-gradle@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0 + uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2 with: cache-read-only: false - name: Configure Gradle Properties diff --git a/.sdkmanrc b/.sdkmanrc index 828308d277ec..eb2990e97224 100644 --- a/.sdkmanrc +++ b/.sdkmanrc @@ -1,3 +1,3 @@ # Enable auto-env through the sdkman_auto_env config # Add key=value pairs of SDKs to use below -java=17.0.12-librca +java=17.0.13-librca diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 93e430897e83..f5b6511d59ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ First off, thank you for taking the time to contribute! :+1: :tada: ### Code of Conduct -This project is governed by the [Spring Code of Conduct](CODE_OF_CONDUCT.adoc). +This project is governed by the [Spring Code of Conduct](https://github.com/spring-projects/spring-framework#coc-ov-file). By participating you are expected to uphold this code. Please report unacceptable behavior to spring-code-of-conduct@spring.io. @@ -65,10 +65,6 @@ follow-up reports will need to be created as new issues with a fresh description #### Submit a Pull Request -1. If you have not previously done so, please sign the -[Contributor License Agreement](https://cla.spring.io/sign/spring). You will be reminded -automatically when you submit the PR. - 1. Should you create an issue first? No, just create the pull request and use the description to provide context and motivation, as you would for an issue. If you want to start a discussion first or have already created an issue, once a pull request is @@ -85,8 +81,13 @@ multiple edits or corrections of the same logical change. See [Rewriting History section of Pro Git](https://git-scm.com/book/en/Git-Tools-Rewriting-History) for an overview of streamlining the commit history. +1. All commits must include a _Signed-off-by_ trailer at the end of each commit message +to indicate that the contributor agrees to the Developer Certificate of Origin. +For additional details, please refer to the blog post +[Hello DCO, Goodbye CLA: Simplifying Contributions to Spring](https://spring.io/blog/2025/01/06/hello-dco-goodbye-cla-simplifying-contributions-to-spring). + 1. Format commit messages using 55 characters for the subject line, 72 characters per line -for the description, followed by the issue fixed, e.g. `Closes gh-22276`. See the +for the description, followed by the issue fixed, for example, `Closes gh-22276`. See the [Commit Guidelines section of Pro Git](https://git-scm.com/book/en/Distributed-Git-Contributing-to-a-Project#Commit-Guidelines) for best practices around commit messages, and use `git log` to see some examples. diff --git a/README.md b/README.md index 672721533336..9dd6a0f15954 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Spring Framework [![Build Status](https://github.com/spring-projects/spring-framework/actions/workflows/build-and-deploy-snapshot.yml/badge.svg?branch=6.1.x)](https://github.com/spring-projects/spring-framework/actions/workflows/build-and-deploy-snapshot.yml?query=branch%3A6.1.x) [![Revved up by Develocity](https://img.shields.io/badge/Revved%20up%20by-Develocity-06A0CE?logo=Gradle&labelColor=02303A)](https://ge.spring.io/scans?search.rootProjectNames=spring) +# Spring Framework [![Build Status](https://github.com/spring-projects/spring-framework/actions/workflows/build-and-deploy-snapshot.yml/badge.svg?branch=main)](https://github.com/spring-projects/spring-framework/actions/workflows/build-and-deploy-snapshot.yml?query=branch%3Amain) [![Revved up by Develocity](https://img.shields.io/badge/Revved%20up%20by-Develocity-06A0CE?logo=Gradle&labelColor=02303A)](https://ge.spring.io/scans?search.rootProjectNames=spring) This is the home of the Spring Framework: the foundation for all [Spring projects](https://spring.io/projects). Collectively the Spring Framework and the family of Spring projects are often referred to simply as "Spring". @@ -6,7 +6,7 @@ Spring provides everything required beyond the Java programming language for cre ## Code of Conduct -This project is governed by the [Spring Code of Conduct](CODE_OF_CONDUCT.adoc). By participating, you are expected to uphold this code of conduct. Please report unacceptable behavior to spring-code-of-conduct@spring.io. +This project is governed by the [Spring Code of Conduct](https://github.com/spring-projects/spring-framework/?tab=coc-ov-file#contributor-code-of-conduct). By participating, you are expected to uphold this code of conduct. Please report unacceptable behavior to spring-code-of-conduct@spring.io. ## Access to Binaries diff --git a/SECURITY.md b/SECURITY.md index 2a50f06bd5b3..d92c8fa94f42 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,16 +1,10 @@ -# Security Policy +# Reporting a Vulnerability + +Please, [open a draft security advisory](https://github.com/spring-projects/security-advisories/security/advisories/new) if you need to disclose and discuss a security issue in private with the Spring Framework team. Note that we only accept reports against [supported versions](https://spring.io/projects/spring-framework#support). + +For more details, check out our [security policy](https://spring.io/security-policy). ## JAR signing Spring Framework JARs released on Maven Central are signed. You'll find more information about the key here: https://spring.io/GPG-KEY-spring.txt - -## Supported Versions - -Please see the -[Spring Framework Versions](https://github.com/spring-projects/spring-framework/wiki/Spring-Framework-Versions) -wiki page. - -## Reporting a Vulnerability - -Please see https://spring.io/security-policy. diff --git a/build.gradle b/build.gradle index c38b3952aa66..d3b944cbfba9 100644 --- a/build.gradle +++ b/build.gradle @@ -2,13 +2,14 @@ plugins { id 'io.freefair.aspectj' version '8.4' apply false // kotlinVersion is managed in gradle.properties id 'org.jetbrains.kotlin.plugin.serialization' version "${kotlinVersion}" apply false - id 'org.jetbrains.dokka' version '1.8.20' + id 'org.jetbrains.dokka' version '1.9.20' id 'com.github.ben-manes.versions' version '0.51.0' id 'com.github.bjornvester.xjc' version '1.8.2' apply false id 'de.undercouch.download' version '5.4.0' id 'io.github.goooler.shadow' version '8.1.8' apply false id 'me.champeau.jmh' version '0.7.2' apply false id 'me.champeau.mrjar' version '0.1.1' + id "net.ltgt.errorprone" version "3.1.0" apply false } ext { @@ -24,17 +25,17 @@ configure(allprojects) { project -> repositories { mavenCentral() maven { - url "https://repo.spring.io/milestone" + url = "https://repo.spring.io/milestone" content { // Netty 5 optional support includeGroup 'io.projectreactor.netty' } } if (version.contains('-')) { - maven { url "https://repo.spring.io/milestone" } + maven { url = "https://repo.spring.io/milestone" } } if (version.endsWith('-SNAPSHOT')) { - maven { url "https://repo.spring.io/snapshot" } + maven { url = "https://repo.spring.io/snapshot" } } } configurations.all { @@ -90,7 +91,7 @@ configure([rootProject] + javaProjects) { project -> "https://docs.oracle.com/en/java/javase/17/docs/api/", "https://jakarta.ee/specifications/platform/9/apidocs/", "https://docs.jboss.org/hibernate/orm/5.6/javadocs/", - "https://eclipse.dev/aspectj/doc/released/aspectj5rt-api", + "https://eclipse.dev/aspectj/doc/latest/runtime-api/", "https://www.quartz-scheduler.org/api/2.3.0/", "https://fasterxml.github.io/jackson-core/javadoc/2.14/", "https://fasterxml.github.io/jackson-databind/javadoc/2.14/", @@ -101,7 +102,7 @@ configure([rootProject] + javaProjects) { project -> // TODO Uncomment link to JUnit 5 docs once we execute Gradle with Java 18+. // See https://github.com/spring-projects/spring-framework/issues/27497 // - // "https://junit.org/junit5/docs/5.10.4/api/", + // "https://junit.org/junit5/docs/5.11.4/api/", "https://www.reactive-streams.org/reactive-streams-1.0.3-javadoc/", //"https://javadoc.io/static/io.rsocket/rsocket-core/1.1.1/", "https://r2dbc.io/spec/1.0.0.RELEASE/api/", diff --git a/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java b/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java index bd4bcba5686e..f955f18ce6fa 100644 --- a/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java +++ b/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,7 +50,7 @@ public void apply(Project project) { project.getPlugins().apply(CheckstylePlugin.class); project.getTasks().withType(Checkstyle.class).forEach(checkstyle -> checkstyle.getMaxHeapSize().set("1g")); CheckstyleExtension checkstyle = project.getExtensions().getByType(CheckstyleExtension.class); - checkstyle.setToolVersion("10.20.1"); + checkstyle.setToolVersion("10.21.2"); checkstyle.getConfigDirectory().set(project.getRootProject().file("src/checkstyle")); String version = SpringJavaFormatPlugin.class.getPackage().getImplementationVersion(); DependencySet checkstyleDependencies = project.getConfigurations().getByName("checkstyle").getDependencies(); diff --git a/buildSrc/src/main/java/org/springframework/build/hint/RuntimeHintsAgentPlugin.java b/buildSrc/src/main/java/org/springframework/build/hint/RuntimeHintsAgentPlugin.java index 0a77f62b6aa7..fab5ab5134b0 100644 --- a/buildSrc/src/main/java/org/springframework/build/hint/RuntimeHintsAgentPlugin.java +++ b/buildSrc/src/main/java/org/springframework/build/hint/RuntimeHintsAgentPlugin.java @@ -27,6 +27,7 @@ import org.gradle.api.attributes.java.TargetJvmVersion; import org.gradle.api.plugins.JavaPlugin; import org.gradle.api.plugins.jvm.JvmTestSuite; +import org.gradle.api.tasks.TaskProvider; import org.gradle.api.tasks.testing.Test; import org.gradle.testing.base.TestingExtension; @@ -52,7 +53,7 @@ public void apply(Project project) { TestingExtension testing = project.getExtensions().getByType(TestingExtension.class); JvmTestSuite jvmTestSuite = (JvmTestSuite) testing.getSuites().getByName("test"); RuntimeHintsAgentExtension agentExtension = createRuntimeHintsAgentExtension(project); - Test agentTest = project.getTasks().create(RUNTIMEHINTS_TEST_TASK, Test.class, test -> { + TaskProvider agentTest = project.getTasks().register(RUNTIMEHINTS_TEST_TASK, Test.class, test -> { test.useJUnitPlatform(options -> { options.includeTags("RuntimeHintsTests"); }); @@ -63,7 +64,7 @@ public void apply(Project project) { test.setClasspath(jvmTestSuite.getSources().getRuntimeClasspath()); test.getJvmArgumentProviders().add(createRuntimeHintsAgentArgumentProvider(project, agentExtension)); }); - project.getTasks().getByName("check", task -> task.dependsOn(agentTest)); + project.getTasks().named("check", task -> task.dependsOn(agentTest)); project.getDependencies().add(CONFIGURATION_NAME, project.project(":spring-core-test")); }); } diff --git a/framework-api/framework-api.gradle b/framework-api/framework-api.gradle index e8477518793e..c8456268c14c 100644 --- a/framework-api/framework-api.gradle +++ b/framework-api/framework-api.gradle @@ -9,7 +9,7 @@ apply from: "${rootDir}/gradle/publications.gradle" repositories { maven { - url "https://repo.spring.io/release" + url = "https://repo.spring.io/release" } } @@ -87,7 +87,7 @@ tasks.register('schemaZip', Zip) { archiveClassifier.set("schema") description = "Builds -${archiveClassifier} archive containing all " + "XSDs for deployment at https://springframework.org/schema." - duplicatesStrategy DuplicatesStrategy.EXCLUDE + duplicatesStrategy = DuplicatesStrategy.EXCLUDE moduleProjects.each { module -> def Properties schemas = new Properties(); diff --git a/framework-docs/antora-playbook.yml b/framework-docs/antora-playbook.yml index bae9f7639d28..62f0f71a8a3f 100644 --- a/framework-docs/antora-playbook.yml +++ b/framework-docs/antora-playbook.yml @@ -36,4 +36,4 @@ runtime: failure_level: warn ui: bundle: - url: https://github.com/spring-io/antora-ui-spring/releases/download/v0.4.17/ui-bundle.zip + url: https://github.com/spring-io/antora-ui-spring/releases/download/v0.4.18/ui-bundle.zip diff --git a/framework-docs/antora.yml b/framework-docs/antora.yml index b6c4f73156a6..87f730b85743 100644 --- a/framework-docs/antora.yml +++ b/framework-docs/antora.yml @@ -21,6 +21,7 @@ asciidoc: table-stripes: 'odd' include-java: 'example$docs-src/main/java/org/springframework/docs' include-kotlin: 'example$docs-src/main/kotlin/org/springframework/docs' + include-xml: 'example$docs-src/main/resources/org/springframework/docs' spring-site: 'https://spring.io' spring-site-blog: '{spring-site}/blog' spring-site-cve: "{spring-site}/security" @@ -41,7 +42,8 @@ asciidoc: spring-framework-reference: '{spring-framework-docs-root}/{spring-version}/reference' # # Other Spring portfolio projects - spring-boot-docs: '{docs-site}/spring-boot/docs/current/reference/html' + spring-boot-docs: '{docs-site}/spring-boot' + spring-boot-docs-ref: '{spring-boot-docs}/reference' spring-boot-issues: '{spring-github-org}/spring-boot/issues' # TODO add more projects / links or just build up on {docs-site}? # TODO rename the below using new conventions diff --git a/framework-docs/framework-docs.gradle b/framework-docs/framework-docs.gradle index 188b2fa255c6..5850c2f51d41 100644 --- a/framework-docs/framework-docs.gradle +++ b/framework-docs/framework-docs.gradle @@ -37,20 +37,36 @@ javadoc { repositories { maven { - url "https://repo.spring.io/release" + url = "https://repo.spring.io/release" } } dependencies { + api(project(":spring-aspects")) api(project(":spring-context")) + api(project(":spring-context-support")) api(project(":spring-jdbc")) api(project(":spring-jms")) + api(project(":spring-test")) api(project(":spring-web")) api(project(":spring-webflux")) + api(project(":spring-webmvc")) + api(project(":spring-websocket")) + api("com.fasterxml.jackson.core:jackson-databind") + api("com.fasterxml.jackson.module:jackson-module-parameter-names") + api("com.mchange:c3p0:0.9.5.5") api("com.oracle.database.jdbc:ojdbc11") + api("io.projectreactor.netty:reactor-netty-http") api("jakarta.jms:jakarta.jms-api") api("jakarta.servlet:jakarta.servlet-api") + api("jakarta.resource:jakarta.resource-api") + api("jakarta.validation:jakarta.validation-api") + api("javax.cache:cache-api") + api("org.apache.activemq:activemq-ra:6.1.2") + api("org.apache.commons:commons-dbcp2:2.11.0") + api("org.aspectj:aspectjweaver") + api("org.eclipse.jetty.websocket:jetty-websocket-jetty-api") api("org.jetbrains.kotlin:kotlin-stdlib") implementation(project(":spring-core-test")) diff --git a/framework-docs/modules/ROOT/nav.adoc b/framework-docs/modules/ROOT/nav.adoc index b7e114263fac..da2650dcec0d 100644 --- a/framework-docs/modules/ROOT/nav.adoc +++ b/framework-docs/modules/ROOT/nav.adoc @@ -60,6 +60,7 @@ **** xref:core/expressions/language-ref/constructors.adoc[] **** xref:core/expressions/language-ref/variables.adoc[] **** xref:core/expressions/language-ref/functions.adoc[] +**** xref:core/expressions/language-ref/varargs.adoc[] **** xref:core/expressions/language-ref/bean-references.adoc[] **** xref:core/expressions/language-ref/operator-ternary.adoc[] **** xref:core/expressions/language-ref/operator-elvis.adoc[] @@ -106,87 +107,6 @@ *** xref:core/appendix/xsd-schemas.adoc[] *** xref:core/appendix/xml-custom.adoc[] *** xref:core/appendix/application-startup-steps.adoc[] -* xref:testing.adoc[] -** xref:testing/introduction.adoc[] -** xref:testing/unit.adoc[] -** xref:testing/integration.adoc[] -** xref:testing/support-jdbc.adoc[] -** xref:testing/testcontext-framework.adoc[] -*** xref:testing/testcontext-framework/key-abstractions.adoc[] -*** xref:testing/testcontext-framework/bootstrapping.adoc[] -*** xref:testing/testcontext-framework/tel-config.adoc[] -*** xref:testing/testcontext-framework/application-events.adoc[] -*** xref:testing/testcontext-framework/test-execution-events.adoc[] -*** xref:testing/testcontext-framework/ctx-management.adoc[] -**** xref:testing/testcontext-framework/ctx-management/xml.adoc[] -**** xref:testing/testcontext-framework/ctx-management/groovy.adoc[] -**** xref:testing/testcontext-framework/ctx-management/javaconfig.adoc[] -**** xref:testing/testcontext-framework/ctx-management/mixed-config.adoc[] -**** xref:testing/testcontext-framework/ctx-management/context-customizers.adoc[] -**** xref:testing/testcontext-framework/ctx-management/initializers.adoc[] -**** xref:testing/testcontext-framework/ctx-management/inheritance.adoc[] -**** xref:testing/testcontext-framework/ctx-management/env-profiles.adoc[] -**** xref:testing/testcontext-framework/ctx-management/property-sources.adoc[] -**** xref:testing/testcontext-framework/ctx-management/dynamic-property-sources.adoc[] -**** xref:testing/testcontext-framework/ctx-management/web.adoc[] -**** xref:testing/testcontext-framework/ctx-management/web-mocks.adoc[] -**** xref:testing/testcontext-framework/ctx-management/caching.adoc[] -**** xref:testing/testcontext-framework/ctx-management/failure-threshold.adoc[] -**** xref:testing/testcontext-framework/ctx-management/hierarchies.adoc[] -*** xref:testing/testcontext-framework/fixture-di.adoc[] -*** xref:testing/testcontext-framework/web-scoped-beans.adoc[] -*** xref:testing/testcontext-framework/tx.adoc[] -*** xref:testing/testcontext-framework/executing-sql.adoc[] -*** xref:testing/testcontext-framework/parallel-test-execution.adoc[] -*** xref:testing/testcontext-framework/support-classes.adoc[] -*** xref:testing/testcontext-framework/aot.adoc[] -** xref:testing/webtestclient.adoc[] -** xref:testing/spring-mvc-test-framework.adoc[] -*** xref:testing/spring-mvc-test-framework/server.adoc[] -*** xref:testing/spring-mvc-test-framework/server-static-imports.adoc[] -*** xref:testing/spring-mvc-test-framework/server-setup-options.adoc[] -*** xref:testing/spring-mvc-test-framework/server-setup-steps.adoc[] -*** xref:testing/spring-mvc-test-framework/server-performing-requests.adoc[] -*** xref:testing/spring-mvc-test-framework/server-defining-expectations.adoc[] -*** xref:testing/spring-mvc-test-framework/async-requests.adoc[] -*** xref:testing/spring-mvc-test-framework/vs-streaming-response.adoc[] -*** xref:testing/spring-mvc-test-framework/server-filters.adoc[] -*** xref:testing/spring-mvc-test-framework/vs-end-to-end-integration-tests.adoc[] -*** xref:testing/spring-mvc-test-framework/server-resources.adoc[] -*** xref:testing/spring-mvc-test-framework/server-htmlunit.adoc[] -**** xref:testing/spring-mvc-test-framework/server-htmlunit/why.adoc[] -**** xref:testing/spring-mvc-test-framework/server-htmlunit/mah.adoc[] -**** xref:testing/spring-mvc-test-framework/server-htmlunit/webdriver.adoc[] -**** xref:testing/spring-mvc-test-framework/server-htmlunit/geb.adoc[] -** xref:testing/spring-mvc-test-client.adoc[] -** xref:testing/appendix.adoc[] -*** xref:testing/annotations.adoc[] -**** xref:testing/annotations/integration-standard.adoc[] -**** xref:testing/annotations/integration-spring.adoc[] -***** xref:testing/annotations/integration-spring/annotation-bootstrapwith.adoc[] -***** xref:testing/annotations/integration-spring/annotation-contextconfiguration.adoc[] -***** xref:testing/annotations/integration-spring/annotation-webappconfiguration.adoc[] -***** xref:testing/annotations/integration-spring/annotation-contexthierarchy.adoc[] -***** xref:testing/annotations/integration-spring/annotation-contextcustomizerfactories.adoc[] -***** xref:testing/annotations/integration-spring/annotation-activeprofiles.adoc[] -***** xref:testing/annotations/integration-spring/annotation-testpropertysource.adoc[] -***** xref:testing/annotations/integration-spring/annotation-dynamicpropertysource.adoc[] -***** xref:testing/annotations/integration-spring/annotation-dirtiescontext.adoc[] -***** xref:testing/annotations/integration-spring/annotation-testexecutionlisteners.adoc[] -***** xref:testing/annotations/integration-spring/annotation-recordapplicationevents.adoc[] -***** xref:testing/annotations/integration-spring/annotation-commit.adoc[] -***** xref:testing/annotations/integration-spring/annotation-rollback.adoc[] -***** xref:testing/annotations/integration-spring/annotation-beforetransaction.adoc[] -***** xref:testing/annotations/integration-spring/annotation-aftertransaction.adoc[] -***** xref:testing/annotations/integration-spring/annotation-sql.adoc[] -***** xref:testing/annotations/integration-spring/annotation-sqlconfig.adoc[] -***** xref:testing/annotations/integration-spring/annotation-sqlmergemode.adoc[] -***** xref:testing/annotations/integration-spring/annotation-sqlgroup.adoc[] -***** xref:testing/annotations/integration-spring/annotation-disabledinaotmode.adoc[] -**** xref:testing/annotations/integration-junit4.adoc[] -**** xref:testing/annotations/integration-junit-jupiter.adoc[] -**** xref:testing/annotations/integration-meta.adoc[] -*** xref:testing/resources.adoc[] * xref:data-access.adoc[] ** xref:data-access/transaction.adoc[] *** xref:data-access/transaction/motivation.adoc[] @@ -245,6 +165,7 @@ **** xref:web/webmvc/mvc-servlet/multipart.adoc[] **** xref:web/webmvc/mvc-servlet/logging.adoc[] *** xref:web/webmvc/filters.adoc[] +*** xref:web/webmvc/message-converters.adoc[] *** xref:web/webmvc/mvc-controller.adoc[] **** xref:web/webmvc/mvc-controller/ann.adoc[] **** xref:web/webmvc/mvc-controller/ann-requestmapping.adoc[] @@ -285,6 +206,7 @@ **** xref:web/webmvc-view/mvc-freemarker.adoc[] **** xref:web/webmvc-view/mvc-groovymarkup.adoc[] **** xref:web/webmvc-view/mvc-script.adoc[] +**** xref:web/webmvc-view/mvc-fragments.adoc[] **** xref:web/webmvc-view/mvc-jsp.adoc[] **** xref:web/webmvc-view/mvc-feeds.adoc[] **** xref:web/webmvc-view/mvc-document.adoc[] @@ -392,6 +314,97 @@ ** xref:web/webflux-test.adoc[] ** xref:rsocket.adoc[] ** xref:web/webflux-reactive-libraries.adoc[] +* xref:testing.adoc[] +** xref:testing/introduction.adoc[] +** xref:testing/unit.adoc[] +** xref:testing/integration.adoc[] +** xref:testing/support-jdbc.adoc[] +** xref:testing/testcontext-framework.adoc[] +*** xref:testing/testcontext-framework/key-abstractions.adoc[] +*** xref:testing/testcontext-framework/bootstrapping.adoc[] +*** xref:testing/testcontext-framework/tel-config.adoc[] +*** xref:testing/testcontext-framework/application-events.adoc[] +*** xref:testing/testcontext-framework/test-execution-events.adoc[] +*** xref:testing/testcontext-framework/ctx-management.adoc[] +**** xref:testing/testcontext-framework/ctx-management/xml.adoc[] +**** xref:testing/testcontext-framework/ctx-management/groovy.adoc[] +**** xref:testing/testcontext-framework/ctx-management/javaconfig.adoc[] +**** xref:testing/testcontext-framework/ctx-management/mixed-config.adoc[] +**** xref:testing/testcontext-framework/ctx-management/context-customizers.adoc[] +**** xref:testing/testcontext-framework/ctx-management/initializers.adoc[] +**** xref:testing/testcontext-framework/ctx-management/inheritance.adoc[] +**** xref:testing/testcontext-framework/ctx-management/env-profiles.adoc[] +**** xref:testing/testcontext-framework/ctx-management/property-sources.adoc[] +**** xref:testing/testcontext-framework/ctx-management/dynamic-property-sources.adoc[] +**** xref:testing/testcontext-framework/ctx-management/web.adoc[] +**** xref:testing/testcontext-framework/ctx-management/web-mocks.adoc[] +**** xref:testing/testcontext-framework/ctx-management/caching.adoc[] +**** xref:testing/testcontext-framework/ctx-management/failure-threshold.adoc[] +**** xref:testing/testcontext-framework/ctx-management/hierarchies.adoc[] +*** xref:testing/testcontext-framework/fixture-di.adoc[] +*** xref:testing/testcontext-framework/bean-overriding.adoc[] +*** xref:testing/testcontext-framework/web-scoped-beans.adoc[] +*** xref:testing/testcontext-framework/tx.adoc[] +*** xref:testing/testcontext-framework/executing-sql.adoc[] +*** xref:testing/testcontext-framework/parallel-test-execution.adoc[] +*** xref:testing/testcontext-framework/support-classes.adoc[] +*** xref:testing/testcontext-framework/aot.adoc[] +** xref:testing/webtestclient.adoc[] +** xref:testing/mockmvc.adoc[] +*** xref:testing/mockmvc/overview.adoc[] +*** xref:testing/mockmvc/setup-options.adoc[] +*** xref:testing/mockmvc/hamcrest.adoc[] +**** xref:testing/mockmvc/hamcrest/static-imports.adoc[] +**** xref:testing/mockmvc/hamcrest/setup.adoc[] +**** xref:testing/mockmvc/hamcrest/setup-steps.adoc[] +**** xref:testing/mockmvc/hamcrest/requests.adoc[] +**** xref:testing/mockmvc/hamcrest/expectations.adoc[] +**** xref:testing/mockmvc/hamcrest/async-requests.adoc[] +**** xref:testing/mockmvc/hamcrest/vs-streaming-response.adoc[] +**** xref:testing/mockmvc/hamcrest/filters.adoc[] +*** xref:testing/mockmvc/assertj.adoc[] +**** xref:testing/mockmvc/assertj/setup.adoc[] +**** xref:testing/mockmvc/assertj/requests.adoc[] +**** xref:testing/mockmvc/assertj/assertions.adoc[] +**** xref:testing/mockmvc/assertj/integration.adoc[] +*** xref:testing/mockmvc/htmlunit.adoc[] +**** xref:testing/mockmvc/htmlunit/why.adoc[] +**** xref:testing/mockmvc/htmlunit/mah.adoc[] +**** xref:testing/mockmvc/htmlunit/webdriver.adoc[] +**** xref:testing/mockmvc/htmlunit/geb.adoc[] +*** xref:testing/mockmvc/vs-end-to-end-integration-tests.adoc[] +*** xref:testing/mockmvc/resources.adoc[] +** xref:testing/spring-mvc-test-client.adoc[] +** xref:testing/appendix.adoc[] +*** xref:testing/annotations.adoc[] +**** xref:testing/annotations/integration-standard.adoc[] +**** xref:testing/annotations/integration-spring.adoc[] +***** xref:testing/annotations/integration-spring/annotation-bootstrapwith.adoc[] +***** xref:testing/annotations/integration-spring/annotation-contextconfiguration.adoc[] +***** xref:testing/annotations/integration-spring/annotation-webappconfiguration.adoc[] +***** xref:testing/annotations/integration-spring/annotation-contexthierarchy.adoc[] +***** xref:testing/annotations/integration-spring/annotation-contextcustomizerfactories.adoc[] +***** xref:testing/annotations/integration-spring/annotation-activeprofiles.adoc[] +***** xref:testing/annotations/integration-spring/annotation-testpropertysource.adoc[] +***** xref:testing/annotations/integration-spring/annotation-dynamicpropertysource.adoc[] +***** xref:testing/annotations/integration-spring/annotation-testbean.adoc[] +***** xref:testing/annotations/integration-spring/annotation-mockitobean.adoc[] +***** xref:testing/annotations/integration-spring/annotation-dirtiescontext.adoc[] +***** xref:testing/annotations/integration-spring/annotation-testexecutionlisteners.adoc[] +***** xref:testing/annotations/integration-spring/annotation-recordapplicationevents.adoc[] +***** xref:testing/annotations/integration-spring/annotation-commit.adoc[] +***** xref:testing/annotations/integration-spring/annotation-rollback.adoc[] +***** xref:testing/annotations/integration-spring/annotation-beforetransaction.adoc[] +***** xref:testing/annotations/integration-spring/annotation-aftertransaction.adoc[] +***** xref:testing/annotations/integration-spring/annotation-sql.adoc[] +***** xref:testing/annotations/integration-spring/annotation-sqlconfig.adoc[] +***** xref:testing/annotations/integration-spring/annotation-sqlmergemode.adoc[] +***** xref:testing/annotations/integration-spring/annotation-sqlgroup.adoc[] +***** xref:testing/annotations/integration-spring/annotation-disabledinaotmode.adoc[] +**** xref:testing/annotations/integration-junit4.adoc[] +**** xref:testing/annotations/integration-junit-jupiter.adoc[] +**** xref:testing/annotations/integration-meta.adoc[] +*** xref:testing/resources.adoc[] * xref:integration.adoc[] ** xref:integration/rest-clients.adoc[] ** xref:integration/jms.adoc[] @@ -439,5 +452,6 @@ ** xref:languages/groovy.adoc[] ** xref:languages/dynamic.adoc[] * xref:appendix.adoc[] -* {spring-framework-wiki}[Wiki] - +* {spring-framework-docs-root}/{spring-version}/javadoc-api/[Java API,window=_blank, role=link-external] +* {spring-framework-api-kdoc}/[Kotlin API,window=_blank, role=link-external] +* {spring-framework-wiki}[Wiki, window=_blank, role=link-external] diff --git a/framework-docs/modules/ROOT/pages/core/aop-api/advice.adoc b/framework-docs/modules/ROOT/pages/core/aop-api/advice.adoc index fd9ecd219a2a..317af250fe94 100644 --- a/framework-docs/modules/ROOT/pages/core/aop-api/advice.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop-api/advice.adoc @@ -33,11 +33,11 @@ arbitrary advice types. This section describes the basic concepts and standard a [[aop-api-advice-around]] === Interception Around Advice -The most fundamental advice type in Spring is interception around advice. +The most fundamental advice type in Spring is _interception around advice_. -Spring is compliant with the AOP `Alliance` interface for around advice that uses method -interception. Classes that implement `MethodInterceptor` and that implement around advice should also implement the -following interface: +Spring is compliant with the AOP Alliance interface for around advice that uses method +interception. Classes that implement around advice should therefore implement the +following `MethodInterceptor` interface from the `org.aopalliance.intercept` package: [source,java,indent=0,subs="verbatim,quotes"] ---- @@ -49,8 +49,8 @@ following interface: The `MethodInvocation` argument to the `invoke()` method exposes the method being invoked, the target join point, the AOP proxy, and the arguments to the method. The -`invoke()` method should return the invocation's result: the return value of the join -point. +`invoke()` method should return the invocation's result: typically the return value of +the join point. The following example shows a simple `MethodInterceptor` implementation: @@ -58,30 +58,30 @@ The following example shows a simple `MethodInterceptor` implementation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class DebugInterceptor implements MethodInterceptor { public Object invoke(MethodInvocation invocation) throws Throwable { System.out.println("Before: invocation=[" + invocation + "]"); - Object rval = invocation.proceed(); + Object result = invocation.proceed(); System.out.println("Invocation returned"); - return rval; + return result; } } ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class DebugInterceptor : MethodInterceptor { override fun invoke(invocation: MethodInvocation): Any { println("Before: invocation=[$invocation]") - val rval = invocation.proceed() + val result = invocation.proceed() println("Invocation returned") - return rval + return result } } ---- @@ -105,7 +105,7 @@ currently define pointcut interfaces. [[aop-api-advice-before]] === Before Advice -A simpler advice type is a before advice. This does not need a `MethodInvocation` +A simpler advice type is a _before advice_. This does not need a `MethodInvocation` object, since it is called only before entering the method. The main advantage of a before advice is that there is no need to invoke the `proceed()` @@ -122,10 +122,6 @@ The following listing shows the `MethodBeforeAdvice` interface: } ---- -(Spring's API design would allow for -field before advice, although the usual objects apply to field interception and it is -unlikely for Spring to ever implement it.) - Note that the return type is `void`. Before advice can insert custom behavior before the join point runs but cannot change the return value. If a before advice throws an exception, it stops further execution of the interceptor chain. The exception @@ -139,7 +135,7 @@ The following example shows a before advice in Spring, which counts all method i ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class CountingBeforeAdvice implements MethodBeforeAdvice { @@ -157,7 +153,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class CountingBeforeAdvice : MethodBeforeAdvice { @@ -176,10 +172,10 @@ TIP: Before advice can be used with any pointcut. [[aop-api-advice-throws]] === Throws Advice -Throws advice is invoked after the return of the join point if the join point threw +_Throws advice_ is invoked after the return of the join point if the join point threw an exception. Spring offers typed throws advice. Note that this means that the `org.springframework.aop.ThrowsAdvice` interface does not contain any methods. It is a -tag interface identifying that the given object implements one or more typed throws +marker interface identifying that the given object implements one or more typed throws advice methods. These should be in the following form: [source,java,indent=0,subs="verbatim,quotes"] @@ -189,15 +185,16 @@ advice methods. These should be in the following form: Only the last argument is required. The method signatures may have either one or four arguments, depending on whether the advice method is interested in the method and -arguments. The next two listing show classes that are examples of throws advice. +arguments. The next two listings show classes that are examples of throws advice. -The following advice is invoked if a `RemoteException` is thrown (including from subclasses): +The following advice is invoked if a `RemoteException` is thrown (including subclasses of +`RemoteException`): [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class RemoteThrowsAdvice implements ThrowsAdvice { @@ -209,7 +206,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class RemoteThrowsAdvice : ThrowsAdvice { @@ -220,15 +217,15 @@ Kotlin:: ---- ====== -Unlike the preceding -advice, the next example declares four arguments, so that it has access to the invoked method, method -arguments, and target object. The following advice is invoked if a `ServletException` is thrown: +Unlike the preceding advice, the next example declares four arguments, so that it has +access to the invoked method, method arguments, and target object. The following advice +is invoked if a `ServletException` is thrown: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ServletThrowsAdviceWithArguments implements ThrowsAdvice { @@ -240,7 +237,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ServletThrowsAdviceWithArguments : ThrowsAdvice { @@ -259,7 +256,7 @@ methods can be combined in a single class. The following listing shows the final ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public static class CombinedThrowsAdvice implements ThrowsAdvice { @@ -275,7 +272,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class CombinedThrowsAdvice : ThrowsAdvice { @@ -304,7 +301,7 @@ TIP: Throws advice can be used with any pointcut. [[aop-api-advice-after-returning]] === After Returning Advice -An after returning advice in Spring must implement the +An _after returning advice_ in Spring must implement the `org.springframework.aop.AfterReturningAdvice` interface, which the following listing shows: [source,java,indent=0,subs="verbatim,quotes"] @@ -326,7 +323,7 @@ not thrown exceptions: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class CountingAfterReturningAdvice implements AfterReturningAdvice { @@ -345,7 +342,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class CountingAfterReturningAdvice : AfterReturningAdvice { @@ -368,7 +365,7 @@ TIP: After returning advice can be used with any pointcut. [[aop-api-advice-introduction]] === Introduction Advice -Spring treats introduction advice as a special kind of interception advice. +Spring treats _introduction advice_ as a special kind of interception advice. Introduction requires an `IntroductionAdvisor` and an `IntroductionInterceptor` that implement the following interface: @@ -420,7 +417,7 @@ introduce the following interface to one or more objects: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public interface Lockable { void lock(); @@ -431,7 +428,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- interface Lockable { fun lock() @@ -480,7 +477,7 @@ The following example shows the example `LockMixin` class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class LockMixin extends DelegatingIntroductionInterceptor implements Lockable { @@ -510,7 +507,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class LockMixin : DelegatingIntroductionInterceptor(), Lockable { @@ -556,7 +553,7 @@ The following example shows our `LockMixinAdvisor` class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class LockMixinAdvisor extends DefaultIntroductionAdvisor { @@ -568,7 +565,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class LockMixinAdvisor : DefaultIntroductionAdvisor(LockMixin(), Lockable::class.java) ---- diff --git a/framework-docs/modules/ROOT/pages/core/aop-api/advised.adoc b/framework-docs/modules/ROOT/pages/core/aop-api/advised.adoc index 46932dfa85f4..639379c2e073 100644 --- a/framework-docs/modules/ROOT/pages/core/aop-api/advised.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop-api/advised.adoc @@ -10,7 +10,7 @@ following methods: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Advisor[] getAdvisors(); @@ -35,7 +35,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun getAdvisors(): Array @@ -90,7 +90,7 @@ manipulating its advice: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Advised advised = (Advised) myObject; Advisor[] advisors = advised.getAdvisors(); @@ -110,7 +110,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val advised = myObject as Advised val advisors = advised.advisors diff --git a/framework-docs/modules/ROOT/pages/core/aop-api/pfb.adoc b/framework-docs/modules/ROOT/pages/core/aop-api/pfb.adoc index 8d79b35bbb0e..7c15b2de343e 100644 --- a/framework-docs/modules/ROOT/pages/core/aop-api/pfb.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop-api/pfb.adoc @@ -196,16 +196,16 @@ follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Person person = (Person) factory.getBean("person"); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- - val person = factory.getBean("person") as Person; + val person = factory.getBean("person") as Person ---- ====== diff --git a/framework-docs/modules/ROOT/pages/core/aop-api/pointcuts.adoc b/framework-docs/modules/ROOT/pages/core/aop-api/pointcuts.adoc index aa1c2726932e..274e9f5e1f78 100644 --- a/framework-docs/modules/ROOT/pages/core/aop-api/pointcuts.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop-api/pointcuts.adoc @@ -128,18 +128,7 @@ the resulting pointcut is effectively the union of the specified patterns.) The following example shows how to use `JdkRegexpMethodPointcut`: -[source,xml,indent=0,subs="verbatim"] ----- - - - - .*set.* - .*absquatulate - - - ----- +include-code::./JdkRegexpConfiguration[tag=snippet,indent=0] Spring provides a convenience class named `RegexpMethodPointcutAdvisor`, which lets us also reference an `Advice` (remember that an `Advice` can be an interceptor, before advice, @@ -147,21 +136,7 @@ throws advice, and others). Behind the scenes, Spring uses a `JdkRegexpMethodPoi Using `RegexpMethodPointcutAdvisor` simplifies wiring, as the one bean encapsulates both pointcut and advice, as the following example shows: -[source,xml,indent=0,subs="verbatim"] ----- - - - - - - - .*set.* - .*absquatulate - - - ----- +include-code::./RegexpConfiguration[tag=snippet,indent=0] You can use `RegexpMethodPointcutAdvisor` with any `Advice` type. @@ -212,7 +187,7 @@ following example shows how to subclass `StaticMethodMatcherPointcut`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- class TestStaticPointcut extends StaticMethodMatcherPointcut { @@ -224,7 +199,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class TestStaticPointcut : StaticMethodMatcherPointcut() { diff --git a/framework-docs/modules/ROOT/pages/core/aop-api/prog.adoc b/framework-docs/modules/ROOT/pages/core/aop-api/prog.adoc index 0247e4a71c61..f8aa774e73b2 100644 --- a/framework-docs/modules/ROOT/pages/core/aop-api/prog.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop-api/prog.adoc @@ -12,7 +12,7 @@ interceptor and one advisor: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ProxyFactory factory = new ProxyFactory(myBusinessInterfaceImpl); factory.addAdvice(myMethodInterceptor); @@ -22,7 +22,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val factory = ProxyFactory(myBusinessInterfaceImpl) factory.addAdvice(myMethodInterceptor) diff --git a/framework-docs/modules/ROOT/pages/core/aop-api/targetsource.adoc b/framework-docs/modules/ROOT/pages/core/aop-api/targetsource.adoc index 5b891654715f..2be131981c0e 100644 --- a/framework-docs/modules/ROOT/pages/core/aop-api/targetsource.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop-api/targetsource.adoc @@ -38,7 +38,7 @@ You can change the target by using the `swap()` method on HotSwappableTargetSour ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HotSwappableTargetSource swapper = (HotSwappableTargetSource) beanFactory.getBean("swapper"); Object oldTarget = swapper.swap(newTarget); @@ -46,7 +46,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val swapper = beanFactory.getBean("swapper") as HotSwappableTargetSource val oldTarget = swapper.swap(newTarget) @@ -152,7 +152,7 @@ The cast is defined as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- PoolingConfig conf = (PoolingConfig) beanFactory.getBean("businessObject"); System.out.println("Max pool size is " + conf.getMaxSize()); @@ -160,7 +160,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val conf = beanFactory.getBean("businessObject") as PoolingConfig println("Max pool size is " + conf.maxSize) diff --git a/framework-docs/modules/ROOT/pages/core/aop/aspectj-programmatic.adoc b/framework-docs/modules/ROOT/pages/core/aop/aspectj-programmatic.adoc index 28664394da12..cb92225b78ae 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/aspectj-programmatic.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/aspectj-programmatic.adoc @@ -15,7 +15,7 @@ The basic usage for this class is very simple, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- // create a factory that can generate a proxy for the given target object AspectJProxyFactory factory = new AspectJProxyFactory(targetObject); @@ -34,7 +34,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- // create a factory that can generate a proxy for the given target object val factory = AspectJProxyFactory(targetObject) diff --git a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/advice.adoc b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/advice.adoc index 55c3b9146650..5f4164b845ca 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/advice.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/advice.adoc @@ -17,7 +17,7 @@ The following example uses an inline pointcut expression. ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; @@ -34,7 +34,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect import org.aspectj.lang.annotation.Before @@ -57,7 +57,7 @@ as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; @@ -74,7 +74,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect import org.aspectj.lang.annotation.Before @@ -101,7 +101,7 @@ You can declare it by using the `@AfterReturning` annotation. ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.AfterReturning; @@ -118,7 +118,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect import org.aspectj.lang.annotation.AfterReturning @@ -146,7 +146,7 @@ access, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.AfterReturning; @@ -165,7 +165,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect import org.aspectj.lang.annotation.AfterReturning @@ -204,7 +204,7 @@ following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.AfterThrowing; @@ -221,7 +221,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect import org.aspectj.lang.annotation.AfterThrowing @@ -247,7 +247,7 @@ The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.AfterThrowing; @@ -266,7 +266,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect import org.aspectj.lang.annotation.AfterThrowing @@ -311,7 +311,7 @@ purposes. The following example shows how to use after finally advice: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.After; @@ -328,7 +328,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect import org.aspectj.lang.annotation.After @@ -417,7 +417,7 @@ The following example shows how to use around advice: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Around; @@ -438,7 +438,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- import org.aspectj.lang.annotation.Aspect import org.aspectj.lang.annotation.Around @@ -500,7 +500,7 @@ You could write the following: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Before("execution(* com.xyz.dao.*.*(..)) && args(account,..)") public void validateAccount(Account account) { @@ -510,7 +510,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Before("execution(* com.xyz.dao.*.*(..)) && args(account,..)") fun validateAccount(account: Account) { @@ -533,7 +533,7 @@ from the advice. This would look as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Pointcut("execution(* com.xyz.dao.*.*(..)) && args(account,..)") private void accountDataAccessOperation(Account account) {} @@ -546,7 +546,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Pointcut("execution(* com.xyz.dao.*.*(..)) && args(account,..)") private fun accountDataAccessOperation(account: Account) { @@ -572,7 +572,7 @@ The following shows the definition of the `@Auditable` annotation: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @@ -583,7 +583,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Retention(AnnotationRetention.RUNTIME) @Target(AnnotationTarget.FUNCTION) @@ -597,7 +597,7 @@ The following shows the advice that matches the execution of `@Auditable` method ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Before("com.xyz.Pointcuts.publicMethod() && @annotation(auditable)") // <1> public void audit(Auditable auditable) { @@ -609,7 +609,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Before("com.xyz.Pointcuts.publicMethod() && @annotation(auditable)") // <1> fun audit(auditable: Auditable) { @@ -630,7 +630,7 @@ you have a generic type like the following: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- public interface Sample { void sampleGenericMethod(T param); @@ -640,7 +640,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- interface Sample { fun sampleGenericMethod(param: T) @@ -656,7 +656,7 @@ tying the advice parameter to the parameter type for which you want to intercept ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Before("execution(* ..Sample+.sampleGenericMethod(*)) && args(param)") public void beforeSampleMethod(MyType param) { @@ -666,7 +666,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Before("execution(* ..Sample+.sampleGenericMethod(*)) && args(param)") fun beforeSampleMethod(param: MyType) { @@ -682,7 +682,7 @@ pointcut as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Before("execution(* ..Sample+.sampleGenericCollectionMethod(*)) && args(param)") public void beforeSampleMethod(Collection param) { @@ -692,7 +692,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Before("execution(* ..Sample+.sampleGenericCollectionMethod(*)) && args(param)") fun beforeSampleMethod(param: Collection) { @@ -756,7 +756,7 @@ The following example shows how to use the `argNames` attribute: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Before( value = "com.xyz.Pointcuts.publicMethod() && target(bean) && @annotation(auditable)", // <1> @@ -771,7 +771,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Before( value = "com.xyz.Pointcuts.publicMethod() && target(bean) && @annotation(auditable)", // <1> @@ -794,7 +794,7 @@ point object, the `argNames` attribute does not need to include it: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Before( value = "com.xyz.Pointcuts.publicMethod() && target(bean) && @annotation(auditable)", // <1> @@ -809,7 +809,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Before( value = "com.xyz.Pointcuts.publicMethod() && target(bean) && @annotation(auditable)", // <1> @@ -833,7 +833,7 @@ the `argNames` attribute: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Before("com.xyz.Pointcuts.publicMethod()") // <1> public void audit(JoinPoint jp) { @@ -844,7 +844,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Before("com.xyz.Pointcuts.publicMethod()") // <1> fun audit(jp: JoinPoint) { @@ -867,7 +867,7 @@ The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Around("execution(List find*(..)) && " + "com.xyz.CommonPointcuts.inDataAccessLayer() && " + @@ -882,7 +882,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Around("execution(List find*(..)) && " + "com.xyz.CommonPointcuts.inDataAccessLayer() && " + @@ -923,7 +923,7 @@ Each of the distinct advice types of a particular aspect is conceptually meant t to the join point directly. As a consequence, an `@AfterThrowing` advice method is not supposed to receive an exception from an accompanying `@After`/`@AfterReturning` method. -As of Spring Framework 5.2.7, advice methods defined in the same `@Aspect` class that +Advice methods defined in the same `@Aspect` class that need to run at the same join point are assigned precedence based on their advice type in the following order, from highest to lowest precedence: `@Around`, `@Before`, `@After`, `@AfterReturning`, `@AfterThrowing`. Note, however, that an `@After` advice method will diff --git a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/aspectj-support.adoc b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/aspectj-support.adoc index 6bbddeaddb80..f3dae9e6d17f 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/aspectj-support.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/aspectj-support.adoc @@ -8,54 +8,8 @@ determines that a bean is advised by one or more aspects, it automatically gener a proxy for that bean to intercept method invocations and ensures that advice is run as needed. -The @AspectJ support can be enabled with XML- or Java-style configuration. In either -case, you also need to ensure that AspectJ's `aspectjweaver.jar` library is on the -classpath of your application (version 1.9 or later). This library is available in the -`lib` directory of an AspectJ distribution or from the Maven Central repository. - - -[[aop-enable-aspectj-java]] -== Enabling @AspectJ Support with Java Configuration - -To enable @AspectJ support with Java `@Configuration`, add the `@EnableAspectJAutoProxy` -annotation, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim",role="primary"] ----- - @Configuration - @EnableAspectJAutoProxy - public class AppConfig { - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim",role="secondary"] ----- - @Configuration - @EnableAspectJAutoProxy - class AppConfig ----- -====== - -[[aop-enable-aspectj-xml]] -== Enabling @AspectJ Support with XML Configuration - -To enable @AspectJ support with XML-based configuration, use the `aop:aspectj-autoproxy` -element, as the following example shows: - -[source,xml,indent=0,subs="verbatim"] ----- - ----- - -This assumes that you use schema support as described in -xref:core/appendix/xsd-schemas.adoc[XML Schema-based configuration]. -See xref:core/appendix/xsd-schemas.adoc#aop[the AOP schema] for how to -import the tags in the `aop` namespace. - - +The @AspectJ support can be enabled with programmatic or XML configuration. In either +case, you also need to ensure that AspectJ's `org.aspectj:aspectjweaver` library is on the +classpath of your application (version 1.9 or later). +include-code::./ApplicationConfiguration[tag=snippet,indent=0] diff --git a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/at-aspectj.adoc b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/at-aspectj.adoc index 5e2e40176e6d..4672c2d547b6 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/at-aspectj.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/at-aspectj.adoc @@ -9,43 +9,12 @@ minimal steps required for a not-very-useful aspect. The first of the two examples shows a regular bean definition in the application context that points to a bean class that is annotated with `@Aspect`: -[source,xml,indent=0,subs="verbatim"] ----- - - - ----- +include-code::./ApplicationConfiguration[tag=snippet,indent=0] The second of the two examples shows the `NotVeryUsefulAspect` class definition, which is annotated with `@Aspect`: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim",role="primary",chomp="-packages",fold="none"] ----- - package com.xyz; - - import org.aspectj.lang.annotation.Aspect; - - @Aspect - public class NotVeryUsefulAspect { - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim",role="secondary",chomp="-packages",fold="none"] ----- - package com.xyz - - import org.aspectj.lang.annotation.Aspect - - @Aspect - class NotVeryUsefulAspect ----- -====== +include-code::./NotVeryUsefulAspect[tag=snippet,indent=0] Aspects (classes annotated with `@Aspect`) can have methods and fields, the same as any other class. They can also contain pointcut, advice, and introduction (inter-type) diff --git a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/example.adoc b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/example.adoc index 896086c9282c..6fd5e242bf37 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/example.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/example.adoc @@ -16,93 +16,9 @@ aspect. Because we want to retry the operation, we need to use around advice so that we can call `proceed` multiple times. The following listing shows the basic aspect implementation: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim",role="primary"] ----- - @Aspect - public class ConcurrentOperationExecutor implements Ordered { +include-code::./ConcurrentOperationExecutor[tag=snippet,indent=0] - private static final int DEFAULT_MAX_RETRIES = 2; - - private int maxRetries = DEFAULT_MAX_RETRIES; - private int order = 1; - - public void setMaxRetries(int maxRetries) { - this.maxRetries = maxRetries; - } - - public int getOrder() { - return this.order; - } - - public void setOrder(int order) { - this.order = order; - } - - @Around("com.xyz.CommonPointcuts.businessService()") // <1> - public Object doConcurrentOperation(ProceedingJoinPoint pjp) throws Throwable { - int numAttempts = 0; - PessimisticLockingFailureException lockFailureException; - do { - numAttempts++; - try { - return pjp.proceed(); - } - catch(PessimisticLockingFailureException ex) { - lockFailureException = ex; - } - } while(numAttempts <= this.maxRetries); - throw lockFailureException; - } - } ----- -<1> References the `businessService` named pointcut defined in xref:core/aop/ataspectj/pointcuts.adoc#aop-common-pointcuts[Sharing Named Pointcut Definitions]. - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim",role="secondary"] ----- - @Aspect - class ConcurrentOperationExecutor : Ordered { - - private val DEFAULT_MAX_RETRIES = 2 - private var maxRetries = DEFAULT_MAX_RETRIES - private var order = 1 - - fun setMaxRetries(maxRetries: Int) { - this.maxRetries = maxRetries - } - - override fun getOrder(): Int { - return this.order - } - - fun setOrder(order: Int) { - this.order = order - } - - @Around("com.xyz.CommonPointcuts.businessService()") // <1> - fun doConcurrentOperation(pjp: ProceedingJoinPoint): Any? { - var numAttempts = 0 - var lockFailureException: PessimisticLockingFailureException - do { - numAttempts++ - try { - return pjp.proceed() - } catch (ex: PessimisticLockingFailureException) { - lockFailureException = ex - } - - } while (numAttempts <= this.maxRetries) - throw lockFailureException - } - } ----- -<1> References the `businessService` named pointcut defined in xref:core/aop/ataspectj/pointcuts.adoc#aop-common-pointcuts[Sharing Named Pointcut Definitions]. -====== +`@Around("com.xyz.CommonPointcuts.businessService()")` references the `businessService` named pointcut defined in xref:core/aop/ataspectj/pointcuts.adoc#aop-common-pointcuts[Sharing Named Pointcut Definitions]. Note that the aspect implements the `Ordered` interface so that we can set the precedence of the aspect higher than the transaction advice (we want a fresh transaction each time we @@ -114,70 +30,15 @@ we have exhausted all of our retry attempts. The corresponding Spring configuration follows: -[source,xml,indent=0,subs="verbatim"] ----- - - - - - - ----- +include-code::./ApplicationConfiguration[tag=snippet,indent=0] To refine the aspect so that it retries only idempotent operations, we might define the following `Idempotent` annotation: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim",role="primary"] ----- - @Retention(RetentionPolicy.RUNTIME) - // marker annotation - public @interface Idempotent { - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim",role="secondary"] ----- - @Retention(AnnotationRetention.RUNTIME) - // marker annotation - annotation class Idempotent ----- -====== +include-code::./service/Idempotent[tag=snippet,indent=0] We can then use the annotation to annotate the implementation of service operations. The change to the aspect to retry only idempotent operations involves refining the pointcut expression so that only `@Idempotent` operations match, as follows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim",role="primary"] ----- - @Around("execution(* com.xyz..service.*.*(..)) && " + - "@annotation(com.xyz.service.Idempotent)") - public Object doConcurrentOperation(ProceedingJoinPoint pjp) throws Throwable { - // ... - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim",role="secondary"] ----- - @Around("execution(* com.xyz..service.*.*(..)) && " + - "@annotation(com.xyz.service.Idempotent)") - fun doConcurrentOperation(pjp: ProceedingJoinPoint): Any? { - // ... - } ----- -====== - - - +include-code::./service/SampleService[tag=snippet,indent=0] diff --git a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/instantiation-models.adoc b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/instantiation-models.adoc index 2e4b54347e17..b8d5eab1b405 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/instantiation-models.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/instantiation-models.adoc @@ -17,7 +17,7 @@ annotation. Consider the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Aspect("perthis(execution(* com.xyz..service.*.*(..)))") public class MyAspect { @@ -33,7 +33,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Aspect("perthis(execution(* com.xyz..service.*.*(..)))") class MyAspect { diff --git a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/introductions.adoc b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/introductions.adoc index e8732d3cc3d9..3244d288f9f9 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/introductions.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/introductions.adoc @@ -9,13 +9,13 @@ You can make an introduction by using the `@DeclareParents` annotation. This ann is used to declare that matching types have a new parent (hence the name). For example, given an interface named `UsageTracked` and an implementation of that interface named `DefaultUsageTracked`, the following aspect declares that all implementors of service -interfaces also implement the `UsageTracked` interface (e.g. for statistics via JMX): +interfaces also implement the `UsageTracked` interface (for example, for statistics via JMX): [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Aspect public class UsageTracking { @@ -33,7 +33,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Aspect class UsageTracking { @@ -63,16 +63,16 @@ you would write the following: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- UsageTracked usageTracked = context.getBean("myService", UsageTracked.class); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- - val usageTracked = context.getBean("myService", UsageTracked.class) + val usageTracked = context.getBean("myService") ---- ====== diff --git a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/pointcuts.adoc b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/pointcuts.adoc index 3b1ef29d767a..35f6b8d1dd2f 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/ataspectj/pointcuts.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/ataspectj/pointcuts.adoc @@ -19,7 +19,7 @@ matches the execution of any method named `transfer`: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Pointcut("execution(* transfer(..))") // the pointcut expression private void anyOldTransfer() {} // the pointcut signature @@ -27,7 +27,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Pointcut("execution(* transfer(..))") // the pointcut expression private fun anyOldTransfer() {} // the pointcut signature @@ -150,7 +150,7 @@ pointcut expressions by name. The following example shows three pointcut express ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz; @@ -174,7 +174,7 @@ trading module. Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz @@ -217,7 +217,7 @@ expressions for this purpose. Such a class typically resembles the following ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary",chomp="-packages",fold="none"] +[source,java,indent=0,subs="verbatim",chomp="-packages",fold="none"] ---- package com.xyz; @@ -279,7 +279,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary",chomp="-packages",fold="none"] +[source,kotlin,indent=0,subs="verbatim",chomp="-packages",fold="none"] ---- package com.xyz diff --git a/framework-docs/modules/ROOT/pages/core/aop/choosing.adoc b/framework-docs/modules/ROOT/pages/core/aop/choosing.adoc index d5432fce394a..910d6c027895 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/choosing.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/choosing.adoc @@ -59,7 +59,7 @@ For example, in the @AspectJ style you can write something like the following: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Pointcut("execution(* get*())") public void propertyAccess() {} @@ -73,7 +73,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Pointcut("execution(* get*())") fun propertyAccess() {} diff --git a/framework-docs/modules/ROOT/pages/core/aop/introduction-defn.adoc b/framework-docs/modules/ROOT/pages/core/aop/introduction-defn.adoc index 128d6cb42884..2c87464cb608 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/introduction-defn.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/introduction-defn.adoc @@ -65,7 +65,7 @@ with less potential for errors. For example, you do not need to invoke the `proc method on the `JoinPoint` used for around advice, and, hence, you cannot fail to invoke it. All advice parameters are statically typed so that you work with advice parameters of -the appropriate type (e.g. the type of the return value from a method execution) rather +the appropriate type (for example, the type of the return value from a method execution) rather than `Object` arrays. The concept of join points matched by pointcuts is the key to AOP, which distinguishes diff --git a/framework-docs/modules/ROOT/pages/core/aop/proxying.adoc b/framework-docs/modules/ROOT/pages/core/aop/proxying.adoc index 44a76320f6f6..3150e07c5df4 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/proxying.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/proxying.adoc @@ -6,22 +6,26 @@ target object. JDK dynamic proxies are built into the JDK, whereas CGLIB is a co open-source class definition library (repackaged into `spring-core`). If the target object to be proxied implements at least one interface, a JDK dynamic -proxy is used. All of the interfaces implemented by the target type are proxied. -If the target object does not implement any interfaces, a CGLIB proxy is created. +proxy is used, and all of the interfaces implemented by the target type are proxied. +If the target object does not implement any interfaces, a CGLIB proxy is created which +is a runtime-generated subclass of the target type. If you want to force the use of CGLIB proxying (for example, to proxy every method defined for the target object, not only those implemented by its interfaces), you can do so. However, you should consider the following issues: -* With CGLIB, `final` methods cannot be advised, as they cannot be overridden in - runtime-generated subclasses. -* As of Spring 4.0, the constructor of your proxied object is NOT called twice anymore, - since the CGLIB proxy instance is created through Objenesis. Only if your JVM does - not allow for constructor bypassing, you might see double invocations and - corresponding debug log entries from Spring's AOP support. -* Your CGLIB proxy usage may face limitations with the JDK 9+ platform module system. - As a typical case, you cannot create a CGLIB proxy for a class from the `java.lang` - package when deploying on the module path. Such cases require a JVM bootstrap flag +* `final` classes cannot be proxied, because they cannot be extended. +* `final` methods cannot be advised, because they cannot be overridden. +* `private` methods cannot be advised, because they cannot be overridden. +* Methods that are not visible – for example, package-private methods in a parent class + from a different package – cannot be advised because they are effectively private. +* The constructor of your proxied object will not be called twice, since the CGLIB proxy + instance is created through Objenesis. However, if your JVM does not allow for + constructor bypassing, you might see double invocations and corresponding debug log + entries from Spring's AOP support. +* Your CGLIB proxy usage may face limitations with the Java Module System. As a typical + case, you cannot create a CGLIB proxy for a class from the `java.lang` package when + deploying on the module path. Such cases require a JVM bootstrap flag `--add-opens=java.base/java.lang=ALL-UNNAMED` which is not available for modules. To force the use of CGLIB proxies, set the value of the `proxy-target-class` attribute @@ -65,15 +69,14 @@ Spring AOP is proxy-based. It is vitally important that you grasp the semantics what that last statement actually means before you write your own aspects or use any of the Spring AOP-based aspects supplied with the Spring Framework. -Consider first the scenario where you have a plain-vanilla, un-proxied, -nothing-special-about-it, straight object reference, as the following -code snippet shows: +Consider first the scenario where you have a plain-vanilla, un-proxied object reference, +as the following code snippet shows: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- public class SimplePojo implements Pojo { @@ -90,7 +93,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- class SimplePojo : Pojo { @@ -115,7 +118,7 @@ image::aop-proxy-plain-pojo-call.png[] ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- public class Main { @@ -129,7 +132,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- fun main() { val pojo = SimplePojo() @@ -148,7 +151,7 @@ image::aop-proxy-call.png[] ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- public class Main { @@ -166,7 +169,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- fun main() { val factory = ProxyFactory(SimplePojo()) @@ -187,26 +190,35 @@ the interceptors (advice) that are relevant to that particular method call. Howe once the call has finally reached the target object (the `SimplePojo` reference in this case), any method calls that it may make on itself, such as `this.bar()` or `this.foo()`, are going to be invoked against the `this` reference, and not the proxy. -This has important implications. It means that self-invocation is not going to result -in the advice associated with a method invocation getting a chance to run. - -Okay, so what is to be done about this? The best approach (the term "best" is used -loosely here) is to refactor your code such that the self-invocation does not happen. -This does entail some work on your part, but it is the best, least-invasive approach. -The next approach is absolutely horrendous, and we hesitate to point it out, precisely -because it is so horrendous. You can (painful as it is to us) totally tie the logic -within your class to Spring AOP, as the following example shows: +This has important implications. It means that self invocation is not going to result +in the advice associated with a method invocation getting a chance to run. In other words, +self invocation via an explicit or implicit `this` reference will bypass the advice. + +To address that, you have the following options. + +Avoid self invocation :: + The best approach (the term "best" is used loosely here) is to refactor your code such + that the self invocation does not happen. This does entail some work on your part, but + it is the best, least-invasive approach. +Inject a self reference :: + An alternative approach is to make use of + xref:core/beans/annotation-config/autowired.adoc#beans-autowired-annotation-self-injection[self injection], + and invoke methods on the proxy via the self reference instead of via `this`. +Use `AopContext.currentProxy()` :: + This last approach is highly discouraged, and we hesitate to point it out, in favor of + the previous options. However, as a last resort you can choose to tie the logic within + your class to Spring AOP, as the following example shows. [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- public class SimplePojo implements Pojo { public void foo() { - // this works, but... gah! + // This works, but it should be avoided if possible. ((Pojo) AopContext.currentProxy()).bar(); } @@ -218,12 +230,12 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- class SimplePojo : Pojo { fun foo() { - // this works, but... gah! + // This works, but it should be avoided if possible. (AopContext.currentProxy() as Pojo).bar() } @@ -234,16 +246,16 @@ Kotlin:: ---- ====== -This totally couples your code to Spring AOP, and it makes the class itself aware of -the fact that it is being used in an AOP context, which flies in the face of AOP. It -also requires some additional configuration when the proxy is being created, as the -following example shows: +The use of `AopContext.currentProxy()` totally couples your code to Spring AOP, and it +makes the class itself aware of the fact that it is being used in an AOP context, which +reduces some of the benefits of AOP. It also requires that the `ProxyFactory` is +configured to expose the proxy, as the following example shows: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- public class Main { @@ -262,7 +274,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- fun main() { val factory = ProxyFactory(SimplePojo()) @@ -277,9 +289,6 @@ Kotlin:: ---- ====== -Finally, it must be noted that AspectJ does not have this self-invocation issue because -it is not a proxy-based AOP framework. - - - +NOTE: AspectJ compile-time weaving and load-time weaving do not have this self-invocation +issue because they apply advice within the bytecode instead of via a proxy. diff --git a/framework-docs/modules/ROOT/pages/core/aop/schema.adoc b/framework-docs/modules/ROOT/pages/core/aop/schema.adoc index c51ad3e976fd..ed66092d2697 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/schema.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/schema.adoc @@ -136,7 +136,7 @@ parameters of the matching names, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- public void monitor(Object service) { // ... @@ -145,7 +145,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- fun monitor(service: Any) { // ... @@ -282,14 +282,14 @@ example, you can declare the method signature as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- public void doAccessCheck(Object retVal) {... ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- fun doAccessCheck(retVal: Any) {... ---- @@ -340,14 +340,14 @@ The type of this parameter constrains matching in the same way as described for ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- public void doRecoveryActions(DataAccessException dataAccessEx) {... ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- fun doRecoveryActions(dataAccessEx: DataAccessException) {... ---- @@ -421,7 +421,7 @@ The implementation of the `doBasicProfiling` advice can be exactly the same as i ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- public Object doBasicProfiling(ProceedingJoinPoint pjp) throws Throwable { // start stopwatch @@ -433,7 +433,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- fun doBasicProfiling(pjp: ProceedingJoinPoint): Any? { // start stopwatch @@ -475,7 +475,7 @@ some around advice used in conjunction with a number of strongly typed parameter ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz.service; @@ -494,7 +494,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz.service @@ -521,7 +521,7 @@ proceed with the method call. The presence of this parameter is an indication th ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz; @@ -545,7 +545,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz @@ -610,7 +610,7 @@ Consider the following driver script: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- public class Boot { @@ -624,7 +624,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- fun main() { val ctx = ClassPathXmlApplicationContext("beans.xml") @@ -714,7 +714,7 @@ The class that backs the `usageTracking` bean would then contain the following m ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- public void recordUsage(UsageTracked usageTracked) { usageTracked.incrementUseCount(); @@ -723,7 +723,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- fun recordUsage(usageTracked: UsageTracked) { usageTracked.incrementUseCount() @@ -742,14 +742,14 @@ following: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- UsageTracked usageTracked = context.getBean("myService", UsageTracked.class); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- val usageTracked = context.getBean("myService", UsageTracked.class) ---- @@ -829,7 +829,7 @@ call `proceed` multiple times. The following listing shows the basic aspect impl ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- public class ConcurrentOperationExecutor implements Ordered { @@ -869,7 +869,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- class ConcurrentOperationExecutor : Ordered { @@ -953,7 +953,7 @@ to annotate the implementation of service operations, as the following example s ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Retention(RetentionPolicy.RUNTIME) // marker annotation @@ -963,7 +963,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Retention(AnnotationRetention.RUNTIME) // marker annotation diff --git a/framework-docs/modules/ROOT/pages/core/aop/using-aspectj.adoc b/framework-docs/modules/ROOT/pages/core/aop/using-aspectj.adoc index edd74750918f..548d27257fb2 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/using-aspectj.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/using-aspectj.adoc @@ -36,7 +36,7 @@ following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz.domain; @@ -50,7 +50,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz.domain @@ -66,7 +66,7 @@ Kotlin:: When used as a marker interface in this way, Spring configures new instances of the annotated type (`Account`, in this case) by using a bean definition (typically prototype-scoped) with the same name as the fully-qualified type name -(`com.xyz.domain.Account`). Since the default name for a bean is the +(`com.xyz.domain.Account`). Since the default name for a bean defined via XML is the fully-qualified name of its type, a convenient way to declare the prototype definition is to omit the `id` attribute, as the following example shows: @@ -84,7 +84,7 @@ can do so directly in the annotation, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz.domain; @@ -98,7 +98,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz.domain @@ -153,14 +153,14 @@ available for use in the body of the constructors, you need to define this on th ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Configurable(preConstruction = true) ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Configurable(preConstruction = true) ---- @@ -177,41 +177,10 @@ either use a build-time Ant or Maven task to do this (see, for example, the {aspectj-docs-devguide}/antTasks.html[AspectJ Development Environment Guide]) or load-time weaving (see xref:core/aop/using-aspectj.adoc#aop-aj-ltw[Load-time Weaving with AspectJ in the Spring Framework]). The `AnnotationBeanConfigurerAspect` itself needs to be configured by Spring (in order to obtain -a reference to the bean factory that is to be used to configure new objects). If you -use Java-based configuration, you can add `@EnableSpringConfigured` to any -`@Configuration` class, as follows: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim",role="primary"] ----- - @Configuration - @EnableSpringConfigured - public class AppConfig { - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim",role="secondary"] ----- - @Configuration - @EnableSpringConfigured - class AppConfig { - } ----- -====== +a reference to the bean factory that is to be used to configure new objects). You can define +the related configuration as follows: -If you prefer XML based configuration, the Spring -xref:core/appendix/xsd-schemas.adoc#context[`context` namespace] -defines a convenient `context:spring-configured` element, which you can use as follows: - -[source,xml,indent=0,subs="verbatim"] ----- - ----- +include-code::./ApplicationConfiguration[tag=snippet,indent=0] Instances of `@Configurable` objects created before the aspect has been configured result in a message being issued to the debug log and no configuration of the @@ -444,7 +413,7 @@ It is a time-based profiler that uses the @AspectJ-style of aspect declaration: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz; @@ -477,7 +446,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz @@ -575,7 +544,7 @@ driver class with a `main(..)` method to demonstrate the LTW in action: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz; @@ -597,7 +566,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz @@ -656,7 +625,7 @@ result: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz; @@ -678,7 +647,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim",chomp="-packages"] ---- package com.xyz @@ -783,52 +752,9 @@ adding one line. (Note that you almost certainly need to use an `ApplicationContext` as your Spring container -- typically, a `BeanFactory` is not enough because the LTW support uses `BeanFactoryPostProcessors`.) -To enable the Spring Framework's LTW support, you need to configure a `LoadTimeWeaver`, -which typically is done by using the `@EnableLoadTimeWeaving` annotation, as follows: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim",role="primary"] ----- - @Configuration - @EnableLoadTimeWeaving - public class AppConfig { - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim",role="secondary"] ----- - @Configuration - @EnableLoadTimeWeaving - class AppConfig { - } ----- -====== +To enable the Spring Framework's LTW support, you need to configure a `LoadTimeWeaver` as follows: -Alternatively, if you prefer XML-based configuration, use the -`` element. Note that the element is defined in the -`context` namespace. The following example shows how to use ``: - -[source,xml,indent=0,subs="verbatim"] ----- - - - - - - ----- +include-code::./ApplicationConfiguration[tag=snippet,indent=0] The preceding configuration automatically defines and registers a number of LTW-specific infrastructure beans, such as a `LoadTimeWeaver` and an `AspectJWeavingEnabler`, for you. @@ -864,63 +790,12 @@ Note that the table lists only the `LoadTimeWeavers` that are autodetected when use the `DefaultContextLoadTimeWeaver`. You can specify exactly which `LoadTimeWeaver` implementation to use. -To specify a specific `LoadTimeWeaver` with Java configuration, implement the -`LoadTimeWeavingConfigurer` interface and override the `getLoadTimeWeaver()` method. +To configure a specific `LoadTimeWeaver`, implement the +`LoadTimeWeavingConfigurer` interface and override the `getLoadTimeWeaver()` method +(or use the XML equivalent). The following example specifies a `ReflectiveLoadTimeWeaver`: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim",role="primary"] ----- - @Configuration - @EnableLoadTimeWeaving - public class AppConfig implements LoadTimeWeavingConfigurer { - - @Override - public LoadTimeWeaver getLoadTimeWeaver() { - return new ReflectiveLoadTimeWeaver(); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim",role="secondary"] ----- - @Configuration - @EnableLoadTimeWeaving - class AppConfig : LoadTimeWeavingConfigurer { - - override fun getLoadTimeWeaver(): LoadTimeWeaver { - return ReflectiveLoadTimeWeaver() - } - } ----- -====== - -If you use XML-based configuration, you can specify the fully qualified class name -as the value of the `weaver-class` attribute on the `` -element. Again, the following example specifies a `ReflectiveLoadTimeWeaver`: - -[source,xml,indent=0,subs="verbatim"] ----- - - - - - - ----- +include-code::./CustomWeaverConfiguration[tag=snippet,indent=0] The `LoadTimeWeaver` that is defined and registered by the configuration can be later retrieved from the Spring container by using the well known name, `loadTimeWeaver`. diff --git a/framework-docs/modules/ROOT/pages/core/aot.adoc b/framework-docs/modules/ROOT/pages/core/aot.adoc index 2a53060d05fd..80a75965d774 100644 --- a/framework-docs/modules/ROOT/pages/core/aot.adoc +++ b/framework-docs/modules/ROOT/pages/core/aot.adoc @@ -140,7 +140,7 @@ Taking our previous example, let's assume that `DataSourceConfiguration` is as f ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration(proxyBeanMethods = false) public class DataSourceConfiguration { @@ -155,7 +155,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration(proxyBeanMethods = false) class DataSourceConfiguration { @@ -176,7 +176,7 @@ The AOT engine will convert the configuration class above to code similar to the ====== Java:: + -[source,java,indent=0,role="primary"] +[source,java,indent=0] ---- /** * Bean definitions for {@link DataSourceConfiguration} @@ -278,7 +278,7 @@ Consider the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration(proxyBeanMethods = false) public class UserConfiguration { @@ -290,6 +290,19 @@ Java:: } ---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @Configuration(proxyBeanMethods = false) + class UserConfiguration { + + @Bean + fun myInterface(): MyInterface = MyImplementation() + + } +---- ====== In the example above, the declared type for the `myInterface` bean is `MyInterface`. @@ -302,7 +315,7 @@ The example above should be rewritten as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration(proxyBeanMethods = false) public class UserConfiguration { @@ -314,6 +327,19 @@ Java:: } ---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @Configuration(proxyBeanMethods = false) + class UserConfiguration { + + @Bean + fun myInterface() = MyImplementation() + + } +---- ====== If you are registering bean definitions programmatically, consider using `RootBeanBefinition` as it allows to specify a `ResolvableType` that handles generics. @@ -379,12 +405,21 @@ Consider the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ClientFactoryBean implements FactoryBean { // ... } ---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + class ClientFactoryBean : FactoryBean { + // ... + } +---- ====== A concrete client declaration should provide a resolved generic for the client, as shown in the following example: @@ -393,7 +428,7 @@ A concrete client declaration should provide a resolved generic for the client, ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration(proxyBeanMethods = false) public class UserConfiguration { @@ -405,6 +440,19 @@ Java:: } ---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @Configuration(proxyBeanMethods = false) + class UserConfiguration { + + @Bean + fun myClient() = ClientFactoryBean(...) + + } +---- ====== If the `FactoryBean` bean definition is registered programmatically, make sure to follow these steps: @@ -419,13 +467,23 @@ The following example showcases a basic definition: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RootBeanDefinition beanDefinition = new RootBeanDefinition(ClientFactoryBean.class); beanDefinition.setTargetType(ResolvableType.forClassWithGenerics(ClientFactoryBean.class, MyClient.class)); // ... registry.registerBeanDefinition("myClient", beanDefinition); ---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + val beanDefinition = RootBeanDefinition(ClientFactoryBean::class.java) + beanDefinition.setTargetType(ResolvableType.forClassWithGenerics(ClientFactoryBean::class.java, MyClient::class.java)); + // ... + registry.registerBeanDefinition("myClient", beanDefinition) +---- ====== [[aot.bestpractices.jpa]] @@ -437,7 +495,7 @@ The JPA persistence unit has to be known upfront for certain optimizations to ap ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Bean LocalContainerEntityManagerFactoryBean customDBEntityManagerFactory(DataSource dataSource) { @@ -447,6 +505,19 @@ Java:: return factoryBean; } ---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @Bean + fun customDBEntityManagerFactory(dataSource: DataSource): LocalContainerEntityManagerFactoryBean { + val factoryBean = LocalContainerEntityManagerFactoryBean() + factoryBean.dataSource = dataSource + factoryBean.setPackagesToScan("com.example.app") + return factoryBean + } +---- ====== To make sure the scanning occurs ahead of time, a `PersistenceManagedTypes` bean must be declared and used by the @@ -456,7 +527,7 @@ factory bean definition, as shown by the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Bean PersistenceManagedTypes persistenceManagedTypes(ResourceLoader resourceLoader) { @@ -472,6 +543,25 @@ Java:: return factoryBean; } ---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @Bean + fun persistenceManagedTypes(resourceLoader: ResourceLoader): PersistenceManagedTypes { + return PersistenceManagedTypesScanner(resourceLoader) + .scan("com.example.app") + } + + @Bean + fun customDBEntityManagerFactory(dataSource: DataSource, managedTypes: PersistenceManagedTypes): LocalContainerEntityManagerFactoryBean { + val factoryBean = LocalContainerEntityManagerFactoryBean() + factoryBean.dataSource = dataSource + factoryBean.setManagedTypes(managedTypes) + return factoryBean + } +---- ====== [[aot.hints]] @@ -489,10 +579,17 @@ The following example makes sure that `config/app.properties` can be loaded from ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- runtimeHints.resources().registerPattern("config/app.properties"); ---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + runtimeHints.resources().registerPattern("config/app.properties") +---- ====== A number of contracts are handled automatically during AOT processing. @@ -523,40 +620,45 @@ It is also possible to register an implementation statically by adding an entry {spring-framework-api}/aot/hint/annotation/Reflective.html[`@Reflective`] provides an idiomatic way to flag the need for reflection on an annotated element. For instance, `@EventListener` is meta-annotated with `@Reflective` since the underlying implementation invokes the annotated method using reflection. -By default, only Spring beans are considered, and an invocation hint is registered for the annotated element. -This can be tuned by specifying a custom `ReflectiveProcessor` implementation via the -`@Reflective` annotation. +Out-of-the-box, only Spring beans are considered but you can opt-in for scanning using `@ReflectiveScan`. +In the example below, all types of the package `com.example.app` and their subpackages are considered: + +include-code::./MyConfiguration[] + +Scanning happens during AOT processing and the types in the target packages do not need to have a class-level annotation to be considered. +This performs a "deep scan" and the presence of `@Reflective`, either directly or as a meta-annotation, is checked on types, fields, constructors, methods, and enclosed elements. + +By default, `@Reflective` registers an invocation hint for the annotated element. +This can be tuned by specifying a custom `ReflectiveProcessor` implementation via the `@Reflective` annotation. Library authors can reuse this annotation for their own purposes. -If components other than Spring beans need to be processed, a `BeanFactoryInitializationAotProcessor` can detect the relevant types and use `ReflectiveRuntimeHintsRegistrar` to process them. +An example of such customization is covered in the next section. -[[aot.hints.register-reflection-for-binding]] -=== `@RegisterReflectionForBinding` +[[aot.hints.register-reflection]] +=== `@RegisterReflection` -{spring-framework-api}/aot/hint/annotation/RegisterReflectionForBinding.html[`@RegisterReflectionForBinding`] is a specialization of `@Reflective` that registers the need for serializing arbitrary types. -A typical use case is the use of DTOs that the container cannot infer, such as using a web client within a method body. +{spring-framework-api}/aot/hint/annotation/RegisterReflection.html[`@RegisterReflection`] is a specialization of `@Reflective` that provides a declarative way of registering reflection for arbitrary types. -`@RegisterReflectionForBinding` can be applied to any Spring bean at the class level, but it can also be applied directly to a method, field, or constructor to better indicate where the hints are actually required. -The following example registers `Account` for serialization. +NOTE: As a specialization of `@Reflective`, this is also detected if you're using `@ReflectiveScan`. -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Component - public class OrderService { +In the following example, public constructors and public methods can be invoked via reflection on `AccountService`: - @RegisterReflectionForBinding(Account.class) - public void process(Order order) { - // ... - } +include-code::./MyConfiguration[tag=snippet,indent=0] - } ----- -====== +`@RegisterReflection` can be applied to any target type at the class level, but it can also be applied directly to a method to better indicate where the hints are actually required. + +`@RegisterReflection` can be used as a meta-annotation to provide more specific needs. +{spring-framework-api}/aot/hint/annotation/RegisterReflectionForBinding.html[`@RegisterReflectionForBinding`] is such composed annotation and registers the need for serializing arbitrary types. +A typical use case is the use of DTOs that the container cannot infer, such as using a web client within a method body. + +The following example registers `Order` for serialization. + +include-code::./OrderService[tag=snippet,indent=0] + +This registers hints for constructors, fields, properties, and record components of `Order`. +Hints are also registered for types transitively used on properties and record components. +In other words, if `Order` exposes others types, hints are registered for those as well. [[aot.hints.testing]] === Testing Runtime Hints @@ -588,7 +690,7 @@ If you forgot to contribute a hint, the test will fail and provide some details [source,txt,indent=0,subs="verbatim,quotes"] ---- org.springframework.docs.core.aot.hints.testing.SampleReflection performReflection -INFO: Spring version:6.0.0-SNAPSHOT +INFO: Spring version: 6.2.0 Missing <"ReflectionHints"> for invocation with arguments ["org.springframework.core.SpringVersion", diff --git a/framework-docs/modules/ROOT/pages/core/appendix/xml-custom.adoc b/framework-docs/modules/ROOT/pages/core/appendix/xml-custom.adoc index 5ca36a86567b..c029f5dd7e08 100644 --- a/framework-docs/modules/ROOT/pages/core/appendix/xml-custom.adoc +++ b/framework-docs/modules/ROOT/pages/core/appendix/xml-custom.adoc @@ -145,7 +145,7 @@ use the `NamespaceHandlerSupport` class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package org.springframework.samples.xml; @@ -161,7 +161,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package org.springframework.samples.xml @@ -202,7 +202,7 @@ we can parse our custom XML content, as you can see in the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package org.springframework.samples.xml; @@ -240,7 +240,7 @@ single `BeanDefinition` represents. Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package org.springframework.samples.xml @@ -416,7 +416,7 @@ The following listing shows the `Component` class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo; @@ -449,7 +449,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo @@ -480,7 +480,7 @@ setter property for the `components` property. The following listing shows such ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo; @@ -522,7 +522,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo @@ -598,7 +598,7 @@ we then create a custom `NamespaceHandler`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo; @@ -614,7 +614,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo @@ -637,7 +637,7 @@ listing shows our custom `BeanDefinitionParser` implementation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo; @@ -688,7 +688,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo @@ -787,7 +787,7 @@ JCache-initializing `BeanDefinition`. The following listing shows our `JCacheIni ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo; @@ -807,7 +807,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo @@ -843,7 +843,7 @@ Next, we need to create the associated `NamespaceHandler`, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo; @@ -861,7 +861,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo @@ -886,7 +886,7 @@ The following listing shows our `BeanDefinitionDecorator` implementation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo; @@ -942,7 +942,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo diff --git a/framework-docs/modules/ROOT/pages/core/appendix/xsd-schemas.adoc b/framework-docs/modules/ROOT/pages/core/appendix/xsd-schemas.adoc index 0752b210d7ac..55b3141dda08 100644 --- a/framework-docs/modules/ROOT/pages/core/appendix/xsd-schemas.adoc +++ b/framework-docs/modules/ROOT/pages/core/appendix/xsd-schemas.adoc @@ -121,7 +121,7 @@ The following example enumeration shows how easy injecting an enum value is: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package jakarta.persistence; @@ -134,7 +134,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package jakarta.persistence @@ -152,7 +152,7 @@ Now consider the following setter of type `PersistenceContextType` and the corre ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package example; @@ -168,7 +168,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package example diff --git a/framework-docs/modules/ROOT/pages/core/beans/annotation-config.adoc b/framework-docs/modules/ROOT/pages/core/beans/annotation-config.adoc index f364ae8ed1af..33e48c41a236 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/annotation-config.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/annotation-config.adoc @@ -20,7 +20,7 @@ can be found in the xref:core/beans/standard-annotations.adoc[relevant section]. [NOTE] ==== Annotation injection is performed before external property injection. Thus, external -configuration (e.g. XML-specified bean properties) effectively overrides the annotations +configuration (for example, XML-specified bean properties) effectively overrides the annotations for properties when wired through mixed approaches. ==== diff --git a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/autowired-primary.adoc b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/autowired-primary.adoc index dcaa7bce6234..af63a56aa661 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/autowired-primary.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/autowired-primary.adoc @@ -1,5 +1,5 @@ [[beans-autowired-annotation-primary]] -= Fine-tuning Annotation-based Autowiring with `@Primary` += Fine-tuning Annotation-based Autowiring with `@Primary` or `@Fallback` Because autowiring by type may lead to multiple candidates, it is often necessary to have more control over the selection process. One way to accomplish this is with Spring's @@ -15,7 +15,7 @@ primary `MovieCatalog`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class MovieConfiguration { @@ -33,7 +33,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class MovieConfiguration { @@ -50,14 +50,57 @@ Kotlin:: ---- ====== -With the preceding configuration, the following `MovieRecommender` is autowired with the -`firstMovieCatalog`: +Alternatively, as of 6.2, there is a `@Fallback` annotation for demarcating +any beans other than the regular ones to be injected. If only one regular +bean is left, it is effectively primary as well: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] +---- + @Configuration + public class MovieConfiguration { + + @Bean + public MovieCatalog firstMovieCatalog() { ... } + + @Bean + @Fallback + public MovieCatalog secondMovieCatalog() { ... } + + // ... + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @Configuration + class MovieConfiguration { + + @Bean + fun firstMovieCatalog(): MovieCatalog { ... } + + @Bean + @Fallback + fun secondMovieCatalog(): MovieCatalog { ... } + + // ... + } +---- +====== + +With both variants of the preceding configuration, the following +`MovieRecommender` is autowired with the `firstMovieCatalog`: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -70,7 +113,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender { diff --git a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/autowired-qualifiers.adoc b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/autowired-qualifiers.adoc index 34bf1e02b903..07073d269281 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/autowired-qualifiers.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/autowired-qualifiers.adoc @@ -1,19 +1,20 @@ [[beans-autowired-annotation-qualifiers]] = Fine-tuning Annotation-based Autowiring with Qualifiers -`@Primary` is an effective way to use autowiring by type with several instances when one -primary candidate can be determined. When you need more control over the selection process, -you can use Spring's `@Qualifier` annotation. You can associate qualifier values -with specific arguments, narrowing the set of type matches so that a specific bean is -chosen for each argument. In the simplest case, this can be a plain descriptive value, as -shown in the following example: +`@Primary` and `@Fallback` are effective ways to use autowiring by type with several +instances when one primary (or non-fallback) candidate can be determined. + +When you need more control over the selection process, you can use Spring's `@Qualifier` +annotation. You can associate qualifier values with specific arguments, narrowing the set +of type matches so that a specific bean is chosen for each argument. In the simplest case, +this can be a plain descriptive value, as shown in the following example: -- [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -27,7 +28,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender { @@ -49,7 +50,7 @@ method parameters, as shown in the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -70,7 +71,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender { @@ -157,44 +158,31 @@ for a non-unique dependency situation, Spring matches the injection point name the same-named candidate, if any (either by bean name or by associated alias). Since version 6.1, this requires the `-parameters` Java compiler flag to be present. +As of 6.2, the container applies fast shortcut resolution for bean name matches, +bypassing the full type matching algorithm when the parameter name matches the +bean name and no type, qualifier or primary conditions override the match. It is +therefore recommendable for your parameter names to match the target bean names. ==== As an alternative for injection by name, consider the JSR-250 `@Resource` annotation which is semantically defined to identify a specific target component by its unique name, with the declared type being irrelevant for the matching process. `@Autowired` has rather -different semantics: After selecting candidate beans by type, the specified `String` +different semantics: after selecting candidate beans by type, the specified `String` qualifier value is considered within those type-selected candidates only (for example, matching an `account` qualifier against beans marked with the same qualifier label). For beans that are themselves defined as a collection, `Map`, or array type, `@Resource` is a fine solution, referring to the specific collection or array bean by unique name. -That said, as of 4.3, you can match collection, `Map`, and array types through Spring's +That said, you can match collection, `Map`, and array types through Spring's `@Autowired` type matching algorithm as well, as long as the element type information is preserved in `@Bean` return type signatures or collection inheritance hierarchies. In this case, you can use qualifier values to select among same-typed collections, as outlined in the previous paragraph. -As of 4.3, `@Autowired` also considers self references for injection (that is, references -back to the bean that is currently injected). Note that self injection is a fallback. -Regular dependencies on other components always have precedence. In that sense, self -references do not participate in regular candidate selection and are therefore in -particular never primary. On the contrary, they always end up as lowest precedence. -In practice, you should use self references as a last resort only (for example, for -calling other methods on the same instance through the bean's transactional proxy). -Consider factoring out the affected methods to a separate delegate bean in such a scenario. -Alternatively, you can use `@Resource`, which may obtain a proxy back to the current bean -by its unique name. - -[NOTE] -==== -Trying to inject the results from `@Bean` methods on the same configuration class is -effectively a self-reference scenario as well. Either lazily resolve such references -in the method signature where it is actually needed (as opposed to an autowired field -in the configuration class) or declare the affected `@Bean` methods as `static`, -decoupling them from the containing configuration class instance and its lifecycle. -Otherwise, such beans are only considered in the fallback phase, with matching beans -on other configuration classes selected as primary candidates instead (if available). -==== +`@Autowired` also considers self references for injection (that is, references back to +the bean that is currently injected). See +xref:core/beans/annotation-config/autowired.adoc#beans-autowired-annotation-self-injection[Self Injection] +for details. `@Autowired` applies to fields, constructors, and multi-argument methods, allowing for narrowing through qualifier annotations at the parameter level. In contrast, `@Resource` @@ -210,7 +198,7 @@ provide the `@Qualifier` annotation within your definition, as the following exa ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @@ -223,7 +211,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) @@ -241,7 +229,7 @@ following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -262,7 +250,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender { @@ -334,7 +322,7 @@ the simple annotation, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @@ -345,7 +333,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) @@ -363,7 +351,7 @@ following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -378,7 +366,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender { @@ -418,7 +406,7 @@ consider the following annotation definition: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @@ -433,7 +421,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) @@ -450,7 +438,7 @@ In this case `Format` is an enum, defined as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public enum Format { VHS, DVD, BLURAY @@ -459,7 +447,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- enum class Format { VHS, DVD, BLURAY @@ -476,7 +464,7 @@ for both attributes: `genre` and `format`, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -502,7 +490,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender { diff --git a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/autowired.adoc b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/autowired.adoc index fcf746542616..8f99eac7d2f2 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/autowired.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/autowired.adoc @@ -13,7 +13,7 @@ You can apply the `@Autowired` annotation to constructors, as the following exam ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -30,7 +30,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender @Autowired constructor( private val customerPreferenceDao: CustomerPreferenceDao) @@ -54,7 +54,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SimpleMovieLister { @@ -71,7 +71,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class SimpleMovieLister { @@ -91,7 +91,7 @@ arguments, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -112,7 +112,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender { @@ -139,7 +139,7 @@ following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -159,7 +159,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender @Autowired constructor( private val customerPreferenceDao: CustomerPreferenceDao) { @@ -186,14 +186,37 @@ implementation type, consider declaring the most specific return type on your fa method (at least as specific as required by the injection points referring to your bean). ==== +.[[beans-autowired-annotation-self-injection]]Self Injection +**** +`@Autowired` also considers self references for injection (that is, references back to +the bean that is currently injected). + +Note, however, that self injection is a fallback mechanism. Regular dependencies on other +components always have precedence. In that sense, self references do not participate in +regular autowiring candidate selection and are therefore in particular never primary. On +the contrary, they always end up as lowest precedence. + +In practice, you should use self references as a last resort only – for example, for +calling other methods on the same instance through the bean's transactional proxy. As an +alternative, consider factoring out the affected methods to a separate delegate bean in +such a scenario. + +Another alternative is to use `@Resource`, which may obtain a proxy back to the current +bean by its unique name. + +====== [NOTE] ==== -As of 4.3, `@Autowired` also considers self references for injection (that is, references -back to the bean that is currently injected). Note that self injection is a fallback. -In practice, you should use self references as a last resort only (for example, for -calling other methods on the same instance through the bean's transactional proxy). -Consider factoring out the affected methods to a separate delegate bean in such a scenario. +Trying to inject the results from `@Bean` methods in the same `@Configuration` class is +effectively a self-reference scenario as well. Either lazily resolve such references +in the method signature where it is actually needed (as opposed to an autowired field +in the configuration class) or declare the affected `@Bean` methods as `static`, +decoupling them from the containing configuration class instance and its lifecycle. +Otherwise, such beans are only considered in the fallback phase, with matching beans +on other configuration classes selected as primary candidates instead (if available). ==== +====== +**** You can also instruct Spring to provide all beans of a particular type from the `ApplicationContext` by adding the `@Autowired` annotation to a field or method that @@ -203,7 +226,7 @@ expects an array of that type, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -216,7 +239,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender { @@ -234,7 +257,7 @@ The same applies for typed collections, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -251,7 +274,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender { @@ -296,7 +319,7 @@ corresponding bean names, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -313,7 +336,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender { @@ -338,7 +361,7 @@ non-required (i.e., by setting the `required` attribute in `@Autowired` to `fals ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SimpleMovieLister { @@ -355,7 +378,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class SimpleMovieLister { @@ -421,15 +444,15 @@ through Java 8's `java.util.Optional`, as the following example shows: } ---- -As of Spring Framework 5.0, you can also use a `@Nullable` annotation (of any kind -in any package -- for example, `javax.annotation.Nullable` from JSR-305) or just leverage -Kotlin built-in null-safety support: +You can also use a `@Nullable` annotation (of any kind in any package -- for example, +`javax.annotation.Nullable` from JSR-305) or just leverage Kotlin built-in null-safety +support: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SimpleMovieLister { @@ -442,7 +465,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class SimpleMovieLister { @@ -465,7 +488,7 @@ an `ApplicationContext` object: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -481,7 +504,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender { diff --git a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/generics-as-qualifiers.adoc b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/generics-as-qualifiers.adoc index f4dac3a0461b..2182e114d79e 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/generics-as-qualifiers.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/generics-as-qualifiers.adoc @@ -9,7 +9,7 @@ configuration: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class MyConfiguration { @@ -28,7 +28,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class MyConfiguration { @@ -50,7 +50,7 @@ used as a qualifier, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Autowired private Store s1; // qualifier, injects the stringStore bean @@ -61,7 +61,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Autowired private lateinit var s1: Store // qualifier, injects the stringStore bean @@ -78,7 +78,7 @@ following example autowires a generic `List`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // Inject all Store beans as long as they have an generic // Store beans will not appear in this list @@ -88,7 +88,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Inject all Store beans as long as they have an generic // Store beans will not appear in this list diff --git a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/postconstruct-and-predestroy-annotations.adoc b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/postconstruct-and-predestroy-annotations.adoc index 4c9a1bdcbf3a..12afd6d4aff4 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/postconstruct-and-predestroy-annotations.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/postconstruct-and-predestroy-annotations.adoc @@ -17,7 +17,7 @@ cleared upon destruction: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class CachingMovieLister { @@ -35,7 +35,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class CachingMovieLister { diff --git a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/resource.adoc b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/resource.adoc index 370471e57d0e..0c24fd143ae9 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/resource.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/resource.adoc @@ -15,7 +15,7 @@ as demonstrated in the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SimpleMovieLister { @@ -31,7 +31,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class SimpleMovieLister { @@ -54,7 +54,7 @@ named `movieFinder` injected into its setter method: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SimpleMovieLister { @@ -69,7 +69,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class SimpleMovieLister { @@ -103,7 +103,7 @@ named "customerPreferenceDao" and then falls back to a primary type match for th ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MovieRecommender { @@ -124,7 +124,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MovieRecommender { diff --git a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/value-annotations.adoc b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/value-annotations.adoc index 967e04af4e70..13f20afe733d 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/annotation-config/value-annotations.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/annotation-config/value-annotations.adoc @@ -7,7 +7,7 @@ ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component public class MovieRecommender { @@ -22,7 +22,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component class MovieRecommender(@Value("\${catalog.name}") private val catalog: String) @@ -35,7 +35,7 @@ With the following configuration: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @PropertySource("classpath:application.properties") @@ -44,7 +44,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @PropertySource("classpath:application.properties") @@ -71,7 +71,7 @@ example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -85,7 +85,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { @@ -101,8 +101,8 @@ NOTE: When configuring a `PropertySourcesPlaceholderConfigurer` using JavaConfig Using the above configuration ensures Spring initialization failure if any `${}` placeholder could not be resolved. It is also possible to use methods like -`setPlaceholderPrefix`, `setPlaceholderSuffix`, or `setValueSeparator` to customize -placeholders. +`setPlaceholderPrefix`, `setPlaceholderSuffix`, `setValueSeparator`, or +`setEscapeCharacter` to customize placeholders. NOTE: Spring Boot configures by default a `PropertySourcesPlaceholderConfigurer` bean that will get properties from `application.properties` and `application.yml` files. @@ -117,7 +117,7 @@ It is possible to provide a default value as following: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component public class MovieRecommender { @@ -132,7 +132,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component class MovieRecommender(@Value("\${catalog.name:defaultCatalog}") private val catalog: String) @@ -148,7 +148,7 @@ provide conversion support for your own custom type, you can provide your own ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -164,7 +164,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { @@ -186,7 +186,7 @@ computed at runtime as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component public class MovieRecommender { @@ -201,7 +201,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component class MovieRecommender( @@ -215,7 +215,7 @@ SpEL also enables the use of more complex data structures: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component public class MovieRecommender { @@ -231,7 +231,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component class MovieRecommender( diff --git a/framework-docs/modules/ROOT/pages/core/beans/basics.adoc b/framework-docs/modules/ROOT/pages/core/beans/basics.adoc index 7102d7ada6be..8c4697771ab9 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/basics.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/basics.adoc @@ -110,14 +110,14 @@ as the local file system, the Java `CLASSPATH`, and so on. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ApplicationContext context = new ClassPathXmlApplicationContext("services.xml", "daos.xml"); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val context = ClassPathXmlApplicationContext("services.xml", "daos.xml") ---- @@ -294,7 +294,7 @@ example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // create and configure beans ApplicationContext context = new ClassPathXmlApplicationContext("services.xml", "daos.xml"); @@ -308,7 +308,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.beans.factory.getBean @@ -331,14 +331,14 @@ The following example shows Groovy configuration: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ApplicationContext context = new GenericGroovyApplicationContext("services.groovy", "daos.groovy"); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val context = GenericGroovyApplicationContext("services.groovy", "daos.groovy") ---- @@ -352,7 +352,7 @@ example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- GenericApplicationContext context = new GenericApplicationContext(); new XmlBeanDefinitionReader(context).loadBeanDefinitions("services.xml", "daos.xml"); @@ -361,7 +361,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val context = GenericApplicationContext() XmlBeanDefinitionReader(context).loadBeanDefinitions("services.xml", "daos.xml") @@ -376,7 +376,7 @@ example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- GenericApplicationContext context = new GenericApplicationContext(); new GroovyBeanDefinitionReader(context).loadBeanDefinitions("services.groovy", "daos.groovy"); @@ -385,7 +385,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val context = GenericApplicationContext() GroovyBeanDefinitionReader(context).loadBeanDefinitions("services.groovy", "daos.groovy") diff --git a/framework-docs/modules/ROOT/pages/core/beans/beanfactory.adoc b/framework-docs/modules/ROOT/pages/core/beans/beanfactory.adoc index 32837957748c..42d873fb82d2 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/beanfactory.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/beanfactory.adoc @@ -90,7 +90,7 @@ you need to programmatically call `addBeanPostProcessor`, as the following examp ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- DefaultListableBeanFactory factory = new DefaultListableBeanFactory(); // populate the factory with bean definitions @@ -104,7 +104,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val factory = DefaultListableBeanFactory() // populate the factory with bean definitions @@ -124,7 +124,7 @@ you need to call its `postProcessBeanFactory` method, as the following example s ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- DefaultListableBeanFactory factory = new DefaultListableBeanFactory(); XmlBeanDefinitionReader reader = new XmlBeanDefinitionReader(factory); @@ -140,7 +140,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val factory = DefaultListableBeanFactory() val reader = XmlBeanDefinitionReader(factory) diff --git a/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc b/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc index 59d53dda1570..8193305116be 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc @@ -59,7 +59,7 @@ is meta-annotated with `@Component`, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @@ -74,7 +74,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Target(AnnotationTarget.TYPE) @Retention(AnnotationRetention.RUNTIME) @@ -103,7 +103,7 @@ customization of the `proxyMode`. The following listing shows the definition of ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @@ -123,7 +123,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Target(AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) @@ -142,7 +142,7 @@ You can then use `@SessionScope` without declaring the `proxyMode` as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Service @SessionScope @@ -153,7 +153,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Service @SessionScope @@ -169,7 +169,7 @@ You can also override the value for the `proxyMode`, as the following example sh ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Service @SessionScope(proxyMode = ScopedProxyMode.INTERFACES) @@ -180,7 +180,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Service @SessionScope(proxyMode = ScopedProxyMode.INTERFACES) @@ -207,7 +207,7 @@ are eligible for such autodetection: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Service public class SimpleMovieLister { @@ -222,7 +222,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Service class SimpleMovieLister(private val movieFinder: MovieFinder) @@ -233,7 +233,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Repository public class JpaMovieFinder implements MovieFinder { @@ -243,7 +243,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Repository class JpaMovieFinder : MovieFinder { @@ -262,7 +262,7 @@ comma- or semicolon- or space-separated list that includes the parent package of ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = "org.example") @@ -273,7 +273,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = ["org.example"]) @@ -380,7 +380,7 @@ and using "`stub`" repositories instead: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = "org.example", @@ -393,7 +393,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = ["org.example"], @@ -438,7 +438,7 @@ annotated classes. The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component public class FactoryMethodComponent { @@ -457,7 +457,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component class FactoryMethodComponent { @@ -497,7 +497,7 @@ support for autowiring of `@Bean` methods. The following example shows how to do ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component public class FactoryMethodComponent { @@ -536,7 +536,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component class FactoryMethodComponent { @@ -589,7 +589,7 @@ The following example shows how to use `InjectionPoint`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component public class FactoryMethodComponent { @@ -603,7 +603,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component class FactoryMethodComponent { @@ -703,7 +703,7 @@ following component classes were detected, the names would be `myMovieLister` an ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Service("myMovieLister") public class SimpleMovieLister { @@ -713,7 +713,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Service("myMovieLister") class SimpleMovieLister { @@ -726,7 +726,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Repository public class MovieFinderImpl implements MovieFinder { @@ -736,7 +736,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Repository class MovieFinderImpl : MovieFinder { @@ -763,7 +763,7 @@ fully qualified class name for the generated bean name. The ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = "org.example", nameGenerator = MyNameGenerator.class) @@ -774,7 +774,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = ["org.example"], nameGenerator = MyNameGenerator::class) @@ -810,7 +810,7 @@ scope within the annotation, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Scope("prototype") @Repository @@ -821,7 +821,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Scope("prototype") @Repository @@ -853,7 +853,7 @@ an annotation and a bean definition shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = "org.example", scopeResolver = MyScopeResolver.class) @@ -864,7 +864,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = ["org.example"], scopeResolver = MyScopeResolver::class) @@ -891,7 +891,7 @@ the following configuration results in standard JDK dynamic proxies: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = "org.example", scopedProxy = ScopedProxyMode.INTERFACES) @@ -902,7 +902,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = ["org.example"], scopedProxy = ScopedProxyMode.INTERFACES) @@ -939,7 +939,7 @@ technique: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component @Qualifier("Action") @@ -950,7 +950,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component @Qualifier("Action") @@ -962,7 +962,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component @Genre("Action") @@ -973,7 +973,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component @Genre("Action") @@ -987,7 +987,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component @Offline @@ -998,7 +998,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component @Offline diff --git a/framework-docs/modules/ROOT/pages/core/beans/context-introduction.adoc b/framework-docs/modules/ROOT/pages/core/beans/context-introduction.adoc index 0f67ba899401..612185813e2f 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/context-introduction.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/context-introduction.adoc @@ -102,7 +102,7 @@ implementations and so can be cast to the `MessageSource` interface. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public static void main(String[] args) { MessageSource resources = new ClassPathXmlApplicationContext("beans.xml"); @@ -113,7 +113,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun main() { val resources = ClassPathXmlApplicationContext("beans.xml") @@ -161,7 +161,7 @@ converted into `String` objects and inserted into placeholders in the lookup mes ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class Example { @@ -181,7 +181,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class Example { @@ -224,7 +224,7 @@ argument.required=Ebagum lad, the ''{0}'' argument is required, I say, required. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public static void main(final String[] args) { MessageSource resources = new ClassPathXmlApplicationContext("beans.xml"); @@ -236,7 +236,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun main() { val resources = ClassPathXmlApplicationContext("beans.xml") @@ -344,7 +344,7 @@ simple class that extends Spring's `ApplicationEvent` base class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class BlockedListEvent extends ApplicationEvent { @@ -363,7 +363,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class BlockedListEvent(source: Any, val address: String, @@ -380,7 +380,7 @@ example shows such a class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class EmailService implements ApplicationEventPublisherAware { @@ -407,7 +407,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class EmailService : ApplicationEventPublisherAware { @@ -447,7 +447,7 @@ shows such a class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class BlockedListNotifier implements ApplicationListener { @@ -465,7 +465,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class BlockedListNotifier : ApplicationListener { @@ -484,7 +484,7 @@ You can register as many event listeners as you wish, but note that, by default, This means that the `publishEvent()` method blocks until all listeners have finished processing the event. One advantage of this synchronous and single-threaded approach is that, when a listener receives an event, it operates inside the transaction context of the publisher if a transaction context is available. -If another strategy for event publication becomes necessary, e.g. asynchronous event processing by default, +If another strategy for event publication becomes necessary, for example, asynchronous event processing by default, see the javadoc for Spring's {spring-framework-api}/context/event/ApplicationEventMulticaster.html[`ApplicationEventMulticaster`] interface and {spring-framework-api}/context/event/SimpleApplicationEventMulticaster.html[`SimpleApplicationEventMulticaster`] implementation for configuration options which can be applied to a custom "applicationEventMulticaster" bean definition. @@ -545,7 +545,7 @@ You can register an event listener on any method of a managed bean by using the ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class BlockedListNotifier { @@ -564,7 +564,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class BlockedListNotifier { @@ -578,6 +578,8 @@ Kotlin:: ---- ====== +NOTE: Do not define such beans to be lazy as the `ApplicationContext` will honour that and will not register the method to listen to events. + The method signature once again declares the event type to which it listens, but, this time, with a flexible name and without implementing a specific listener interface. The event type can also be narrowed through generics as long as the actual event type @@ -591,7 +593,7 @@ following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @EventListener({ContextStartedEvent.class, ContextRefreshedEvent.class}) public void handleContextStart() { @@ -601,7 +603,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @EventListener(ContextStartedEvent::class, ContextRefreshedEvent::class) fun handleContextStart() { @@ -621,7 +623,7 @@ The following example shows how our notifier can be rewritten to be invoked only ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @EventListener(condition = "#blEvent.content == 'my-event'") public void processBlockedListEvent(BlockedListEvent blEvent) { @@ -631,7 +633,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @EventListener(condition = "#blEvent.content == 'my-event'") fun processBlockedListEvent(blEvent: BlockedListEvent) { @@ -677,7 +679,7 @@ method signature to return the event that should be published, as the following ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @EventListener public ListUpdateEvent handleBlockedListEvent(BlockedListEvent event) { @@ -688,7 +690,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @EventListener fun handleBlockedListEvent(event: BlockedListEvent): ListUpdateEvent { @@ -717,7 +719,7 @@ The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @EventListener @Async @@ -728,7 +730,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @EventListener @Async @@ -763,7 +765,7 @@ annotation to the method declaration, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @EventListener @Order(42) @@ -774,7 +776,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @EventListener @Order(42) @@ -797,7 +799,7 @@ can create the following listener definition to receive only `EntityCreatedEvent ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @EventListener public void onPersonCreated(EntityCreatedEvent event) { @@ -807,7 +809,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @EventListener fun onPersonCreated(event: EntityCreatedEvent) { @@ -829,7 +831,7 @@ environment provides. The following event shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class EntityCreatedEvent extends ApplicationEvent implements ResolvableTypeProvider { @@ -846,7 +848,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class EntityCreatedEvent(entity: T) : ApplicationEvent(entity), ResolvableTypeProvider { @@ -864,7 +866,7 @@ Finally, as with classic `ApplicationListener` implementations, the actual multi happens via a context-wide `ApplicationEventMulticaster` at runtime. By default, this is a `SimpleApplicationEventMulticaster` with synchronous event publication in the caller thread. This can be replaced/customized through an "applicationEventMulticaster" bean definition, -e.g. for processing all events asynchronously and/or for handling listener exceptions: +for example, for processing all events asynchronously and/or for handling listener exceptions: [source,java,indent=0,subs="verbatim,quotes"] ---- @@ -936,7 +938,7 @@ Here is an example of instrumentation in the `AnnotationConfigApplicationContext ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // create a startup step and start recording StartupStep scanPackages = getApplicationStartup().start("spring.context.base-packages.scan"); @@ -950,7 +952,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // create a startup step and start recording val scanPackages = getApplicationStartup().start("spring.context.base-packages.scan") diff --git a/framework-docs/modules/ROOT/pages/core/beans/context-load-time-weaver.adoc b/framework-docs/modules/ROOT/pages/core/beans/context-load-time-weaver.adoc index 25943592c078..3857be9891e8 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/context-load-time-weaver.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/context-load-time-weaver.adoc @@ -11,7 +11,7 @@ To enable load-time weaving, you can add the `@EnableLoadTimeWeaving` to one of ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableLoadTimeWeaving @@ -21,7 +21,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableLoadTimeWeaving diff --git a/framework-docs/modules/ROOT/pages/core/beans/definition.adoc b/framework-docs/modules/ROOT/pages/core/beans/definition.adoc index 56db659f5836..4be43e5371c9 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/definition.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/definition.adoc @@ -57,9 +57,9 @@ The following table describes these properties: In addition to bean definitions that contain information on how to create a specific bean, the `ApplicationContext` implementations also permit the registration of existing objects that are created outside the container (by users). This is done by accessing the -ApplicationContext's `BeanFactory` through the `getBeanFactory()` method, which returns -the `DefaultListableBeanFactory` implementation. `DefaultListableBeanFactory` supports -this registration through the `registerSingleton(..)` and `registerBeanDefinition(..)` +ApplicationContext's `BeanFactory` through the `getAutowireCapableBeanFactory()` method, +which returns the `DefaultListableBeanFactory` implementation. `DefaultListableBeanFactory` +supports this registration through the `registerSingleton(..)` and `registerBeanDefinition(..)` methods. However, typical applications work solely with beans defined through regular bean definition metadata. @@ -78,19 +78,20 @@ lead to concurrent access exceptions, inconsistent state in the bean container, [[beans-definition-overriding]] == Overriding Beans -Bean overriding is happening when a bean is registered using an identifier that is -already allocated. While bean overriding is possible, it makes the configuration harder -to read and this feature will be deprecated in a future release. +Bean overriding occurs when a bean is registered using an identifier that is already +allocated. While bean overriding is possible, it makes the configuration harder to read. + +WARNING: Bean overriding will be deprecated in a future release. To disable bean overriding altogether, you can set the `allowBeanDefinitionOverriding` -flag to `false` on the `ApplicationContext` before it is refreshed. In such setup, an +flag to `false` on the `ApplicationContext` before it is refreshed. In such a setup, an exception is thrown if bean overriding is used. -By default, the container logs every bean overriding at `INFO` level so that you can -adapt your configuration accordingly. While not recommended, you can silence those logs -by setting the `allowBeanDefinitionOverriding` flag to `true`. +By default, the container logs every attempt to override a bean at `INFO` level so that +you can adapt your configuration accordingly. While not recommended, you can silence +those logs by setting the `allowBeanDefinitionOverriding` flag to `true`. -.Java-configuration +.Java Configuration **** If you use Java Configuration, a corresponding `@Bean` method always silently overrides a scanned bean class with the same component name as long as the return type of the @@ -98,6 +99,10 @@ a scanned bean class with the same component name as long as the return type of the `@Bean` factory method in favor of any pre-declared constructor on the bean class. **** +NOTE: We acknowledge that overriding beans in test scenarios is convenient, and there is +explicit support for this as of Spring Framework 6.2. Please refer to +xref:testing/testcontext-framework/bean-overriding.adoc[this section] for more details. + [[beans-beanname]] @@ -293,7 +298,7 @@ The following example shows a class that would work with the preceding bean defi ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ClientService { private static ClientService clientService = new ClientService(); @@ -307,7 +312,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ClientService private constructor() { companion object { @@ -373,7 +378,7 @@ The following example shows the corresponding class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class DefaultServiceLocator { @@ -387,7 +392,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class DefaultServiceLocator { companion object { @@ -423,7 +428,7 @@ The following example shows the corresponding class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class DefaultServiceLocator { @@ -443,7 +448,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class DefaultServiceLocator { companion object { diff --git a/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-autowire.adoc b/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-autowire.adoc index 36d9a9750275..fa7ba3de4840 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-autowire.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-autowire.adoc @@ -124,5 +124,21 @@ These techniques are useful for beans that you never want to be injected into ot by autowiring. It does not mean that an excluded bean cannot itself be configured by using autowiring. Rather, the bean itself is not a candidate for autowiring other beans. +[NOTE] +==== +As of 6.2, `@Bean` methods support two variants of the autowire candidate flag: +`autowireCandidate` and `defaultCandidate`. + +When using xref:core/beans/annotation-config/autowired-qualifiers.adoc[qualifiers], +a bean marked with `defaultCandidate=false` is only available for injection points +where an additional qualifier indication is present. This is useful for restricted +delegates that are supposed to be injectable in certain areas but are not meant to +get in the way of beans of the same type in other places. Such a bean will never +get injected by plain declared type only, rather by type plus specific qualifier. + +In contrast, `autowireCandidate=false` behaves exactly like the `autowire-candidate` +attribute as explained above: Such a bean will never get injected by type at all. +==== + diff --git a/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-collaborators.adoc b/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-collaborators.adoc index ad4358b3a03e..2559135e4d92 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-collaborators.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-collaborators.adoc @@ -34,7 +34,7 @@ injection: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SimpleMovieLister { @@ -52,7 +52,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // a constructor so that the Spring container can inject a MovieFinder class SimpleMovieLister(private val movieFinder: MovieFinder) { @@ -77,7 +77,7 @@ being instantiated. Consider the following class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package x.y; @@ -91,7 +91,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package x.y @@ -127,7 +127,7 @@ by type without help. Consider the following class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package examples; @@ -148,7 +148,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package examples @@ -221,7 +221,7 @@ then have to look as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package examples; @@ -239,7 +239,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package examples @@ -265,7 +265,7 @@ on container specific interfaces, base classes, or annotations. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SimpleMovieLister { @@ -283,7 +283,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class SimpleMovieLister { @@ -440,7 +440,7 @@ The following example shows the corresponding `ExampleBean` class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ExampleBean { @@ -466,7 +466,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ExampleBean { lateinit var beanOne: AnotherBean @@ -503,7 +503,7 @@ The following example shows the corresponding `ExampleBean` class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ExampleBean { @@ -524,7 +524,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ExampleBean( private val beanOne: AnotherBean, @@ -557,7 +557,7 @@ The following example shows the corresponding `ExampleBean` class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ExampleBean { @@ -581,7 +581,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ExampleBean private constructor() { companion object { diff --git a/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-dependson.adoc b/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-dependson.adoc index 9a076d3d563b..fdf50af50671 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-dependson.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-dependson.adoc @@ -3,7 +3,7 @@ If a bean is a dependency of another bean, that usually means that one bean is set as a property of another. Typically you accomplish this with the -xref:core/beans/dependencies/factory-properties-detailed.adoc#beans-ref-element[`` element>] +xref:core/beans/dependencies/factory-properties-detailed.adoc#beans-ref-element[`` element] in XML-based metadata or through xref:core/beans/dependencies/factory-autowire.adoc[autowiring]. However, sometimes dependencies between beans are less direct. An example is when a static diff --git a/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-lazy-init.adoc b/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-lazy-init.adoc index 59fd3a319b9e..353cf0ae6e1e 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-lazy-init.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-lazy-init.adoc @@ -10,33 +10,25 @@ pre-instantiation of a singleton bean by marking the bean definition as being lazy-initialized. A lazy-initialized bean tells the IoC container to create a bean instance when it is first requested, rather than at startup. -In XML, this behavior is controlled by the `lazy-init` attribute on the `` -element, as the following example shows: +This behavior is controlled by the `@Lazy` annotation or in XML the `lazy-init` attribute on the `` element, as +the following example shows: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - ----- +include-code::./ApplicationConfiguration[tag=snippet,indent=0] When the preceding configuration is consumed by an `ApplicationContext`, the `lazy` bean is not eagerly pre-instantiated when the `ApplicationContext` starts, -whereas the `not.lazy` bean is eagerly pre-instantiated. +whereas the `notLazy` one is eagerly pre-instantiated. However, when a lazy-initialized bean is a dependency of a singleton bean that is not lazy-initialized, the `ApplicationContext` creates the lazy-initialized bean at startup, because it must satisfy the singleton's dependencies. The lazy-initialized bean is injected into a singleton bean elsewhere that is not lazy-initialized. -You can also control lazy-initialization at the container level by using the -`default-lazy-init` attribute on the `` element, as the following example shows: +You can also control lazy-initialization for a set of beans by using the `@Lazy` annotation on your `@Configuration` +annotated class or in XML using the `default-lazy-init` attribute on the `` element, as the following example +shows: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - ----- +include-code::./LazyConfiguration[tag=snippet,indent=0] diff --git a/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-method-injection.adoc b/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-method-injection.adoc index 3738a55f8188..a8096c29972b 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-method-injection.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-method-injection.adoc @@ -21,7 +21,7 @@ shows this approach: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages",fold="none"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages",fold="none"] ---- package fiona.apple; @@ -60,7 +60,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages",fold="none"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages",fold="none"] ---- package fiona.apple @@ -120,8 +120,6 @@ dynamically generate a subclass that overrides the method. subclasses cannot be `final`, and the method to be overridden cannot be `final`, either. * Unit-testing a class that has an `abstract` method requires you to subclass the class yourself and to supply a stub implementation of the `abstract` method. -* Concrete methods are also necessary for component scanning, which requires concrete - classes to pick up. * A further key limitation is that lookup methods do not work with factory methods and in particular not with `@Bean` methods in configuration classes, since, in that case, the container is not in charge of creating the instance and therefore cannot create @@ -137,7 +135,7 @@ the reworked example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages",fold="none"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages",fold="none"] ---- package fiona.apple; @@ -160,7 +158,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages",fold="none"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages",fold="none"] ---- package fiona.apple @@ -220,7 +218,7 @@ method through the `@Lookup` annotation, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public abstract class CommandManager { @@ -237,7 +235,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- abstract class CommandManager { @@ -260,7 +258,7 @@ declared return type of the lookup method: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public abstract class CommandManager { @@ -277,7 +275,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- abstract class CommandManager { @@ -293,11 +291,6 @@ Kotlin:: ---- ====== -Note that you should typically declare such annotated lookup methods with a concrete -stub implementation, in order for them to be compatible with Spring's component -scanning rules where abstract classes get ignored by default. This limitation does not -apply to explicitly registered or explicitly imported bean classes. - [TIP] ==== Another way of accessing differently scoped target beans is an `ObjectFactory`/ @@ -324,7 +317,7 @@ the following class, which has a method called `computeValue` that we want to ov ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MyValueCalculator { @@ -338,7 +331,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MyValueCalculator { @@ -358,7 +351,7 @@ interface provides the new method definition, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- /** * meant to be used to override the existing computeValue(String) @@ -377,7 +370,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- /** * meant to be used to override the existing computeValue(String) diff --git a/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-properties-detailed.adoc b/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-properties-detailed.adoc index 6a48e8f9589b..90fd4106a1ad 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-properties-detailed.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/dependencies/factory-properties-detailed.adoc @@ -168,8 +168,8 @@ listings shows how to use the `parent` attribute: [source,xml,indent=0,subs="verbatim,quotes"] ---- - - + + @@ -354,7 +354,7 @@ The following Java class and bean definition show how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SomeClass { @@ -368,7 +368,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class SomeClass { lateinit var accounts: Map @@ -418,14 +418,14 @@ The preceding example is equivalent to the following Java code: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- exampleBean.setEmail(""); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- exampleBean.email = "" ---- @@ -449,14 +449,14 @@ The preceding configuration is equivalent to the following Java code: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- exampleBean.setEmail(null); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- exampleBean.email = null ---- diff --git a/framework-docs/modules/ROOT/pages/core/beans/environment.adoc b/framework-docs/modules/ROOT/pages/core/beans/environment.adoc index ac8085f0e287..3b1c8898bedd 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/environment.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/environment.adoc @@ -43,7 +43,7 @@ Consider the first use case in a practical application that requires a ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Bean public DataSource dataSource() { @@ -57,7 +57,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Bean fun dataSource(): DataSource { @@ -79,7 +79,7 @@ now looks like the following listing: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Bean(destroyMethod = "") public DataSource dataSource() throws Exception { @@ -90,7 +90,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Bean(destroyMethod = "") fun dataSource(): DataSource { @@ -128,7 +128,7 @@ can rewrite the `dataSource` configuration as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @Profile("development") @@ -147,7 +147,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @Profile("development") @@ -171,7 +171,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @Profile("production") @@ -188,7 +188,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @Profile("production") @@ -233,7 +233,7 @@ of creating a custom composed annotation. The following example defines a custom ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @@ -244,7 +244,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) @@ -272,7 +272,7 @@ the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -300,7 +300,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { @@ -454,7 +454,7 @@ it programmatically against the `Environment` API which is available through an ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); ctx.getEnvironment().setActiveProfiles("development"); @@ -464,7 +464,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val ctx = AnnotationConfigApplicationContext().apply { environment.setActiveProfiles("development") @@ -491,14 +491,14 @@ activates multiple profiles: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ctx.getEnvironment().setActiveProfiles("profile1", "profile2"); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- ctx.getEnvironment().setActiveProfiles("profile1", "profile2") ---- @@ -523,7 +523,7 @@ the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @Profile("default") @@ -541,7 +541,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @Profile("default") @@ -578,7 +578,7 @@ hierarchy of property sources. Consider the following listing: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ApplicationContext ctx = new GenericApplicationContext(); Environment env = ctx.getEnvironment(); @@ -588,7 +588,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val ctx = GenericApplicationContext() val env = ctx.environment @@ -643,7 +643,7 @@ current `Environment`. The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ConfigurableApplicationContext ctx = new GenericApplicationContext(); MutablePropertySources sources = ctx.getEnvironment().getPropertySources(); @@ -652,7 +652,7 @@ sources.addFirst(new MyPropertySource()); Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val ctx = GenericApplicationContext() val sources = ctx.environment.propertySources @@ -684,7 +684,7 @@ a call to `testBean.getName()` returns `myTestBean`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @PropertySource("classpath:/com/myco/app.properties") @@ -704,7 +704,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @PropertySource("classpath:/com/myco/app.properties") @@ -729,7 +729,7 @@ environment, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @PropertySource("classpath:/com/${my.placeholder:default/path}/app.properties") @@ -749,7 +749,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @PropertySource("classpath:/com/\${my.placeholder:default/path}/app.properties") diff --git a/framework-docs/modules/ROOT/pages/core/beans/factory-extension.adoc b/framework-docs/modules/ROOT/pages/core/beans/factory-extension.adoc index 93e8e7a81df2..cf9e68e3a8eb 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/factory-extension.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/factory-extension.adoc @@ -123,7 +123,7 @@ The following listing shows the custom `BeanPostProcessor` implementation class ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package scripting; @@ -145,7 +145,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package scripting @@ -205,7 +205,7 @@ The following Java application runs the preceding code and configuration: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import org.springframework.context.ApplicationContext; import org.springframework.context.support.ClassPathXmlApplicationContext; @@ -224,7 +224,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.beans.factory.getBean diff --git a/framework-docs/modules/ROOT/pages/core/beans/factory-nature.adoc b/framework-docs/modules/ROOT/pages/core/beans/factory-nature.adoc index 7ed7cd011825..f6d310715a8d 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/factory-nature.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/factory-nature.adoc @@ -72,7 +72,7 @@ no-argument signature. With Java configuration, you can use the `initMethod` att ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ExampleBean { @@ -84,7 +84,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ExampleBean { @@ -107,7 +107,7 @@ The preceding example has almost exactly the same effect as the following exampl ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class AnotherExampleBean implements InitializingBean { @@ -120,7 +120,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class AnotherExampleBean : InitializingBean { @@ -144,7 +144,7 @@ based on the given configuration but no further activity with external bean acce Otherwise there is a risk for an initialization deadlock. For a scenario where expensive post-initialization activity is to be triggered, -e.g. asynchronous database preparation steps, your bean should either implement +for example, asynchronous database preparation steps, your bean should either implement `SmartInitializingSingleton.afterSingletonsInstantiated()` or rely on the context refresh event: implementing `ApplicationListener` or declaring its annotation equivalent `@EventListener(ContextRefreshedEvent.class)`. @@ -187,7 +187,7 @@ xref:core/beans/java/bean-annotation.adoc#beans-java-lifecycle-callbacks[Receivi ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ExampleBean { @@ -199,7 +199,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ExampleBean { @@ -221,7 +221,7 @@ The preceding definition has almost exactly the same effect as the following def ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class AnotherExampleBean implements DisposableBean { @@ -234,7 +234,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class AnotherExampleBean : DisposableBean { @@ -295,7 +295,7 @@ following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class DefaultBlogService implements BlogService { @@ -316,7 +316,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class DefaultBlogService : BlogService { @@ -551,7 +551,7 @@ declared on the `ConfigurableApplicationContext` interface, as the following exa ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.support.ClassPathXmlApplicationContext; @@ -573,7 +573,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.context.support.ClassPathXmlApplicationContext @@ -607,7 +607,7 @@ bean creation phase and its subsequent initial publication, they need to be decl `volatile` or guarded by a common lock whenever accessed. Note that concurrent access to such configuration state in singleton bean instances, -e.g. for controller instances or repository instances, is perfectly thread-safe after +for example, for controller instances or repository instances, is perfectly thread-safe after such safe initial publication from the container side. This includes common singleton `FactoryBean` instances which are processed within the general singleton lock as well. @@ -617,7 +617,7 @@ structures (or in `volatile` fields for simple cases) as per common Java guideli Deeper `Lifecycle` integration as shown above involves runtime-mutable state such as a `runnable` field which will have to be declared as `volatile`. While the common -lifecycle callbacks follow a certain order, e.g. a start callback is guaranteed to +lifecycle callbacks follow a certain order, for example, a start callback is guaranteed to only happen after full initialization and a stop callback only after an initial start, there is a special case with the common stop before destroy arrangement: It is strongly recommended that the internal state in any such bean also allows for an immediate diff --git a/framework-docs/modules/ROOT/pages/core/beans/factory-scopes.adoc b/framework-docs/modules/ROOT/pages/core/beans/factory-scopes.adoc index 6049003235d4..df5b753514a8 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/factory-scopes.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/factory-scopes.adoc @@ -251,7 +251,7 @@ to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RequestScope @Component @@ -262,7 +262,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RequestScope @Component @@ -301,7 +301,7 @@ When using annotation-driven components or Java configuration, you can use the ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SessionScope @Component @@ -312,7 +312,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SessionScope @Component @@ -350,7 +350,7 @@ following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ApplicationScope @Component @@ -361,7 +361,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ApplicationScope @Component @@ -584,14 +584,14 @@ underlying scope: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Object get(String name, ObjectFactory objectFactory) ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun get(name: String, objectFactory: ObjectFactory<*>): Any ---- @@ -606,14 +606,14 @@ the underlying scope: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Object remove(String name) ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun remove(name: String): Any ---- @@ -626,14 +626,14 @@ destroyed or when the specified object in the scope is destroyed: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- void registerDestructionCallback(String name, Runnable destructionCallback) ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun registerDestructionCallback(name: String, destructionCallback: Runnable) ---- @@ -648,14 +648,14 @@ The following method obtains the conversation identifier for the underlying scop ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- String getConversationId() ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun getConversationId(): String ---- @@ -677,14 +677,14 @@ method to register a new `Scope` with the Spring container: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- void registerScope(String scopeName, Scope scope); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun registerScope(scopeName: String, scope: Scope) ---- @@ -710,7 +710,7 @@ implementations. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Scope threadScope = new SimpleThreadScope(); beanFactory.registerScope("thread", threadScope); @@ -718,7 +718,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val threadScope = SimpleThreadScope() beanFactory.registerScope("thread", threadScope) diff --git a/framework-docs/modules/ROOT/pages/core/beans/java/basic-concepts.adoc b/framework-docs/modules/ROOT/pages/core/beans/java/basic-concepts.adoc index 5b9277838956..80e28de0a8f5 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/java/basic-concepts.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/java/basic-concepts.adoc @@ -19,7 +19,7 @@ The simplest possible `@Configuration` class reads as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -33,7 +33,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { @@ -64,7 +64,7 @@ This prevents the same `@Bean` method from accidentally being invoked through a Java method call, which helps to reduce subtle bugs that can be hard to track down. When `@Bean` methods are declared within classes that are not annotated with -`@Configuration` - or when `@Configuration(proxyBeanMethods=false)` is declared -, +`@Configuration`, or when `@Configuration(proxyBeanMethods=false)` is declared, they are referred to as being processed in a "lite" mode. In such scenarios, `@Bean` methods are effectively a general-purpose factory method mechanism without special runtime processing (that is, without generating a CGLIB subclass for it). diff --git a/framework-docs/modules/ROOT/pages/core/beans/java/bean-annotation.adoc b/framework-docs/modules/ROOT/pages/core/beans/java/bean-annotation.adoc index 4e089707ac84..dcc181d51586 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/java/bean-annotation.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/java/bean-annotation.adoc @@ -25,7 +25,7 @@ the method name. The following example shows a `@Bean` method declaration: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -39,7 +39,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { @@ -75,7 +75,7 @@ configurations by implementing interfaces with bean definitions on default metho ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public interface BaseConfig { @@ -99,7 +99,7 @@ return type, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -113,7 +113,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { @@ -153,7 +153,7 @@ parameter, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -167,7 +167,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { @@ -211,7 +211,7 @@ on the `bean` element, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class BeanOne { @@ -244,7 +244,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class BeanOne { @@ -291,7 +291,7 @@ The following example shows how to prevent an automatic destruction callback for ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Bean(destroyMethod = "") public DataSource dataSource() throws NamingException { @@ -301,7 +301,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Bean(destroyMethod = "") fun dataSource(): DataSource { @@ -326,7 +326,7 @@ following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -344,7 +344,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { @@ -382,7 +382,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class MyConfiguration { @@ -397,7 +397,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class MyConfiguration { @@ -431,7 +431,7 @@ it resembles the following: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // an HTTP Session-scoped bean exposed as a proxy @Bean @@ -451,7 +451,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // an HTTP Session-scoped bean exposed as a proxy @Bean @@ -479,7 +479,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -493,7 +493,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { @@ -517,7 +517,7 @@ The following example shows how to set a number of aliases for a bean: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -531,7 +531,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { @@ -559,7 +559,7 @@ annotation, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -574,7 +574,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { diff --git a/framework-docs/modules/ROOT/pages/core/beans/java/composing-configuration-classes.adoc b/framework-docs/modules/ROOT/pages/core/beans/java/composing-configuration-classes.adoc index d09faa734e08..0ef19ea633c3 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/java/composing-configuration-classes.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/java/composing-configuration-classes.adoc @@ -16,7 +16,7 @@ another configuration class, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class ConfigA { @@ -40,7 +40,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class ConfigA { @@ -67,7 +67,7 @@ following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public static void main(String[] args) { ApplicationContext ctx = new AnnotationConfigApplicationContext(ConfigB.class); @@ -80,7 +80,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.beans.factory.getBean @@ -123,7 +123,7 @@ classes, each depending on beans declared in the others: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class ServiceConfig { @@ -163,7 +163,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.beans.factory.getBean @@ -219,7 +219,7 @@ parameter-based injection, as in the preceding example. Avoid access to locally defined beans within a `@PostConstruct` method on the same configuration class. This effectively leads to a circular reference since non-static `@Bean` methods semantically require a fully initialized configuration class instance to be called on. With circular references -disallowed (e.g. in Spring Boot 2.6+), this may trigger a `BeanCurrentlyInCreationException`. +disallowed (for example, in Spring Boot 2.6+), this may trigger a `BeanCurrentlyInCreationException`. Also, be particularly careful with `BeanPostProcessor` and `BeanFactoryPostProcessor` definitions through `@Bean`. Those should usually be declared as `static @Bean` methods, not triggering the @@ -234,7 +234,7 @@ The following example shows how one bean can be autowired to another bean: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class ServiceConfig { @@ -283,7 +283,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.beans.factory.getBean @@ -353,7 +353,7 @@ configuration classes themselves. The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class ServiceConfig { @@ -371,7 +371,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class ServiceConfig { @@ -397,7 +397,7 @@ abstract class-based `@Configuration` classes. Consider the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class ServiceConfig { @@ -447,7 +447,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.beans.factory.getBean @@ -504,10 +504,47 @@ get a type hierarchy of `RepositoryConfig` implementations. In this way, navigating `@Configuration` classes and their dependencies becomes no different than the usual process of navigating interface-based code. -TIP: If you want to influence the startup creation order of certain beans, consider -declaring some of them as `@Lazy` (for creation on first access instead of on startup) -or as `@DependsOn` certain other beans (making sure that specific other beans are -created before the current bean, beyond what the latter's direct dependencies imply). + +[[beans-java-startup]] +== Influencing the Startup of `@Bean`-defined Singletons + +If you want to influence the startup creation order of certain singleton beans, consider +declaring some of them as `@Lazy` for creation on first access instead of on startup. + +`@DependsOn` forces certain other beans to be initialized first, making sure that +the specified beans are created before the current bean, beyond what the latter's +direct dependencies imply. + +[[beans-java-startup-background]] +=== Background Initialization + +As of 6.2, there is a background initialization option: `@Bean(bootstrap=BACKGROUND)` +allows for singling out specific beans for background initialization, covering the +entire bean creation step for each such bean on context startup. + +Dependent beans with non-lazy injection points automatically wait for the bean instance +to be completed. All regular background initializations are forced to complete at the end +of context startup. Only beans additionally marked as `@Lazy` are allowed to be completed +later (up until the first actual access). + +Background initialization typically goes together with `@Lazy` (or `ObjectProvider`) +injection points in dependent beans. Otherwise, the main bootstrap thread is going to +block when an actual background-initialized bean instance needs to be injected early. + +This form of concurrent startup applies to individual beans: if such a bean depends on +other beans, they need to have been initialized already, either simply through being +declared earlier or through `@DependsOn` which enforces initialization in the main +bootstrap thread before background initialization for the affected bean is triggered. + +[NOTE] +==== +A `bootstrapExecutor` bean of type `Executor` must be declared for background +bootstrapping to be actually active. Otherwise, the background markers will be ignored at +runtime. + +The bootstrap executor may be a bounded executor just for startup purposes or a shared +thread pool which serves for other purposes as well. +==== [[beans-java-conditional]] @@ -533,7 +570,7 @@ method that returns `true` or `false`. For example, the following listing shows ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { @@ -553,7 +590,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- override fun matches(context: ConditionContext, metadata: AnnotatedTypeMetadata): Boolean { // Read the @Profile annotation attributes @@ -612,7 +649,7 @@ The following example shows the `AppConfig` configuration class in Java and Kotl ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -634,7 +671,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { @@ -686,7 +723,7 @@ jdbc.password= ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public static void main(String[] args) { ApplicationContext ctx = new ClassPathXmlApplicationContext("classpath:/com/acme/system-test-config.xml"); @@ -697,7 +734,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun main() { val ctx = ClassPathXmlApplicationContext("classpath:/com/acme/system-test-config.xml") @@ -757,7 +794,7 @@ annotation to achieve "`Java-centric`" configuration that uses XML as needed: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @ImportResource("classpath:/com/acme/properties-config.xml") @@ -792,7 +829,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @ImportResource("classpath:/com/acme/properties-config.xml") @@ -846,7 +883,7 @@ jdbc.password= ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public static void main(String[] args) { ApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class); @@ -857,7 +894,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.beans.factory.getBean diff --git a/framework-docs/modules/ROOT/pages/core/beans/java/configuration-annotation.adoc b/framework-docs/modules/ROOT/pages/core/beans/java/configuration-annotation.adoc index d265db7e7585..0ed5254a1a82 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/java/configuration-annotation.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/java/configuration-annotation.adoc @@ -17,7 +17,7 @@ as having one bean method call another, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -36,7 +36,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { @@ -72,7 +72,7 @@ following example shows how to use lookup method injection: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public abstract class CommandManager { public Object process(Object commandState) { @@ -90,7 +90,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- abstract class CommandManager { fun process(commandState: Any): Any { @@ -115,7 +115,7 @@ the abstract `createCommand()` method is overridden in such a way that it looks ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Bean @Scope("prototype") @@ -139,7 +139,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Bean @Scope("prototype") @@ -172,7 +172,7 @@ Consider the following example, which shows a `@Bean` annotated method being cal ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class AppConfig { @@ -200,7 +200,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class AppConfig { diff --git a/framework-docs/modules/ROOT/pages/core/beans/java/instantiating-container.adoc b/framework-docs/modules/ROOT/pages/core/beans/java/instantiating-container.adoc index 137bfe28398b..3618e839043f 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/java/instantiating-container.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/java/instantiating-container.adoc @@ -27,7 +27,7 @@ XML-free usage of the Spring container, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public static void main(String[] args) { ApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class); @@ -38,7 +38,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.beans.factory.getBean @@ -58,7 +58,7 @@ as input to the constructor, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public static void main(String[] args) { ApplicationContext ctx = new AnnotationConfigApplicationContext(MyServiceImpl.class, Dependency1.class, Dependency2.class); @@ -69,7 +69,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.beans.factory.getBean @@ -97,7 +97,7 @@ example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public static void main(String[] args) { AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); @@ -111,7 +111,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.beans.factory.getBean @@ -136,7 +136,7 @@ To enable component scanning, you can annotate your `@Configuration` class as fo ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = "com.acme") // <1> @@ -148,7 +148,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = ["com.acme"]) // <1> @@ -183,7 +183,7 @@ following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public static void main(String[] args) { AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); @@ -195,7 +195,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun main() { val ctx = AnnotationConfigApplicationContext() diff --git a/framework-docs/modules/ROOT/pages/core/beans/standard-annotations.adoc b/framework-docs/modules/ROOT/pages/core/beans/standard-annotations.adoc index d9929bea3d03..666edbeb6142 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/standard-annotations.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/standard-annotations.adoc @@ -33,7 +33,7 @@ Instead of `@Autowired`, you can use `@jakarta.inject.Inject` as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import jakarta.inject.Inject; @@ -55,7 +55,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import jakarta.inject.Inject @@ -83,7 +83,7 @@ preceding example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import jakarta.inject.Inject; import jakarta.inject.Provider; @@ -106,7 +106,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import jakarta.inject.Inject @@ -131,7 +131,7 @@ you should use the `@Named` annotation, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import jakarta.inject.Inject; import jakarta.inject.Named; @@ -151,7 +151,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import jakarta.inject.Inject import jakarta.inject.Named @@ -190,7 +190,7 @@ a `required` attribute. The following pair of examples show how to use `@Inject` ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SimpleMovieLister { @@ -203,7 +203,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class SimpleMovieLister { @@ -225,7 +225,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import jakarta.inject.Inject; import jakarta.inject.Named; @@ -246,7 +246,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import jakarta.inject.Inject import jakarta.inject.Named @@ -269,7 +269,7 @@ It is very common to use `@Component` without specifying a name for the componen ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import jakarta.inject.Inject; import jakarta.inject.Named; @@ -290,7 +290,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import jakarta.inject.Inject import jakarta.inject.Named @@ -313,7 +313,7 @@ exact same way as when you use Spring annotations, as the following example show ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = "org.example") @@ -324,7 +324,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan(basePackages = ["org.example"]) diff --git a/framework-docs/modules/ROOT/pages/core/databuffer-codec.adoc b/framework-docs/modules/ROOT/pages/core/databuffer-codec.adoc index afa9a50dc96a..d25bd9769899 100644 --- a/framework-docs/modules/ROOT/pages/core/databuffer-codec.adoc +++ b/framework-docs/modules/ROOT/pages/core/databuffer-codec.adoc @@ -29,7 +29,7 @@ a `DataBuffer` implementation and that does not involve allocation. Note that WebFlux applications do not create a `DataBufferFactory` directly but instead access it through the `ServerHttpResponse` or the `ClientHttpRequest` on the client side. -The type of factory depends on the underlying client or server, e.g. +The type of factory depends on the underlying client or server, for example, `NettyDataBufferFactory` for Reactor Netty, `DefaultDataBufferFactory` for others. @@ -82,7 +82,7 @@ to use the convenience methods in `DataBufferUtils` that apply release or retain `DataBufferUtils` offers a number of utility methods to operate on data buffers: -* Join a stream of data buffers into a single buffer possibly with zero copy, e.g. via +* Join a stream of data buffers into a single buffer possibly with zero copy, for example, via composite buffers, if that's supported by the underlying byte buffer API. * Turn `InputStream` or NIO `Channel` into `Flux`, and vice versa a `Publisher` into `OutputStream` or NIO `Channel`. @@ -144,7 +144,7 @@ a serialization error occurs while populating the buffer with data. For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- DataBuffer buffer = factory.allocateBuffer(); boolean release = true; @@ -162,7 +162,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val buffer = factory.allocateBuffer() var release = true diff --git a/framework-docs/modules/ROOT/pages/core/expressions/beandef.adoc b/framework-docs/modules/ROOT/pages/core/expressions/beandef.adoc index 7617ab15ef70..a246534089cd 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/beandef.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/beandef.adoc @@ -1,219 +1,34 @@ [[expressions-beandef]] = Expressions in Bean Definitions -You can use SpEL expressions with XML-based or annotation-based configuration metadata for -defining `BeanDefinition` instances. In both cases, the syntax to define the expression is of the -form `#{ }`. - - - -[[expressions-beandef-xml-based]] -== XML Configuration - -A property or constructor argument value can be set by using expressions, as the following -example shows: - -[source,xml,indent=0,subs="verbatim"] ----- - - - - - ----- +You can use SpEL expressions with configuration metadata for defining bean instances. In both +cases, the syntax to define the expression is of the form `#{ }`. All beans in the application context are available as predefined variables with their common bean name. This includes standard context beans such as `environment` (of type `org.springframework.core.env.Environment`) as well as `systemProperties` and `systemEnvironment` (of type `Map`) for access to the runtime environment. -The following example shows access to the `systemProperties` bean as a SpEL variable: - -[source,xml,indent=0,subs="verbatim"] ----- - - - - - ----- - -Note that you do not have to prefix the predefined variable with the `#` symbol here. - -You can also refer to other bean properties by name, as the following example shows: - -[source,xml,indent=0,subs="verbatim"] ----- - - - - - - - - - - - ----- - - - -[[expressions-beandef-annotation-based]] -== Annotation Configuration - To specify a default value, you can place the `@Value` annotation on fields, methods, -and method or constructor parameters. +and method or constructor parameters (or XML equivalent). The following example sets the default value of a field: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - public class FieldValueTestBean { - - @Value("#{ systemProperties['user.region'] }") - private String defaultLocale; - - public void setDefaultLocale(String defaultLocale) { - this.defaultLocale = defaultLocale; - } - - public String getDefaultLocale() { - return this.defaultLocale; - } - } ----- +include-code::./FieldValueTestBean[tag=snippet,indent=0] -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - class FieldValueTestBean { - - @Value("#{ systemProperties['user.region'] }") - var defaultLocale: String? = null - } ----- -====== +Note that you do not have to prefix the predefined variable with the `#` symbol here. The following example shows the equivalent but on a property setter method: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - public class PropertyValueTestBean { - - private String defaultLocale; - - @Value("#{ systemProperties['user.region'] }") - public void setDefaultLocale(String defaultLocale) { - this.defaultLocale = defaultLocale; - } - - public String getDefaultLocale() { - return this.defaultLocale; - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - class PropertyValueTestBean { - - @Value("#{ systemProperties['user.region'] }") - var defaultLocale: String? = null - } ----- -====== +include-code::./PropertyValueTestBean[tag=snippet,indent=0] Autowired methods and constructors can also use the `@Value` annotation, as the following examples show: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - public class SimpleMovieLister { - - private MovieFinder movieFinder; - private String defaultLocale; - - @Autowired - public void configure(MovieFinder movieFinder, - @Value("#{ systemProperties['user.region'] }") String defaultLocale) { - this.movieFinder = movieFinder; - this.defaultLocale = defaultLocale; - } - - // ... - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - class SimpleMovieLister { - - private lateinit var movieFinder: MovieFinder - private lateinit var defaultLocale: String - - @Autowired - fun configure(movieFinder: MovieFinder, - @Value("#{ systemProperties['user.region'] }") defaultLocale: String) { - this.movieFinder = movieFinder - this.defaultLocale = defaultLocale - } - - // ... - } ----- -====== - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - public class MovieRecommender { - - private String defaultLocale; - - private CustomerPreferenceDao customerPreferenceDao; - - public MovieRecommender(CustomerPreferenceDao customerPreferenceDao, - @Value("#{systemProperties['user.country']}") String defaultLocale) { - this.customerPreferenceDao = customerPreferenceDao; - this.defaultLocale = defaultLocale; - } - - // ... - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - class MovieRecommender(private val customerPreferenceDao: CustomerPreferenceDao, - @Value("#{systemProperties['user.country']}") private val defaultLocale: String) { - // ... - } ----- -====== - +include-code::./SimpleMovieLister[tag=snippet,indent=0] +include-code::./MovieRecommender[tag=snippet,indent=0] +You can also refer to other bean properties by name, as the following example shows: +include-code::./ShapeGuess[tag=snippet,indent=0] diff --git a/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc b/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc index 7a8ed5d421a4..bf253821b4e2 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc @@ -12,7 +12,7 @@ expression, `Hello World`. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); Expression exp = parser.parseExpression("'Hello World'"); // <1> @@ -22,7 +22,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val exp = parser.parseExpression("'Hello World'") // <1> @@ -51,7 +51,7 @@ literal, `Hello World`. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); Expression exp = parser.parseExpression("'Hello World'.concat('!')"); // <1> @@ -61,7 +61,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val exp = parser.parseExpression("'Hello World'.concat('!')") // <1> @@ -77,7 +77,7 @@ string literal, `Hello World`. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); @@ -89,7 +89,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() @@ -110,7 +110,7 @@ The following example shows how to use dot notation to get the length of a strin ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); @@ -122,7 +122,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() @@ -140,7 +140,7 @@ example shows. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); Expression exp = parser.parseExpression("new String('hello world').toUpperCase()"); // <1> @@ -150,7 +150,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val exp = parser.parseExpression("new String('hello world').toUpperCase()") // <1> @@ -173,7 +173,7 @@ reference the `name` property in a boolean expression. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // Create and set a calendar GregorianCalendar c = new GregorianCalendar(); @@ -195,7 +195,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Create and set a calendar val c = GregorianCalendar() @@ -275,7 +275,7 @@ being placed in it. The following example shows how to do so. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- class Simple { public List booleanList = new ArrayList<>(); @@ -296,7 +296,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class Simple { var booleanList: MutableList = ArrayList() @@ -339,7 +339,7 @@ automatically grow a `List`. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- class Demo { public List list; @@ -364,7 +364,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class Demo { var list: List? = null @@ -444,18 +444,27 @@ 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. -* `OFF` (default): The compiler is switched off. -* `IMMEDIATE`: In immediate mode, the expressions are compiled as soon as possible. This - is typically after the first interpreted evaluation. If the compiled expression fails - (typically due to a type changing, as described earlier), the caller of the expression - evaluation receives an exception. -* `MIXED`: In mixed mode, the expressions silently switch between interpreted and - compiled mode over time. After some number of interpreted runs, they switch to compiled - form and, if something goes wrong with the compiled form (such as a type changing, as - described earlier), the expression automatically switches back to interpreted form - again. Sometime later, it may generate another compiled form and switch to it. - Basically, the exception that the user gets in `IMMEDIATE` mode is instead handled - internally. +`OFF` :: + The compiler is switched off, and all expressions will be evaluated in _interpreted_ + mode. This is the default mode. +`IMMEDIATE` :: + In immediate mode, expressions are compiled as soon as possible, typically after the + first interpreted evaluation. If evaluation of the compiled expression fails (for + example, due to a type changing, as described earlier), the caller of the expression + evaluation receives an exception. If the types of various expression elements change + over time, consider switching to `MIXED` mode or turning off the compiler. +`MIXED` :: + In mixed mode, expression evaluation silently switches between _interpreted_ and + _compiled_ over time. After some number of successful interpreted runs, the expression + gets compiled. If evaluation of the compiled expression fails (for example, due to a + type changing), that failure will be caught internally, and the system will switch back + to interpreted mode for the given expression. Basically, the exception that the caller + receives in `IMMEDIATE` mode is instead handled internally. Sometime later, the + compiler may generate another compiled form and switch to it. This cycle of switching + between interpreted and compiled mode will continue until the system determines that it + does not make sense to continue trying — for example, when a certain failure threshold + has been reached — at which point the system will permanently switch to interpreted + mode for the given expression. `IMMEDIATE` mode exists because `MIXED` mode could cause issues for expressions that have side effects. If a compiled expression blows up after partially succeeding, it @@ -470,7 +479,7 @@ following example shows how to do so. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, this.getClass().getClassLoader()); @@ -486,7 +495,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val config = SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, this.javaClass.classLoader) @@ -525,10 +534,11 @@ following kinds of expressions cannot be compiled. * Expressions involving assignment * Expressions relying on the conversion service -* Expressions using custom resolvers or accessors +* Expressions using custom resolvers * Expressions using overloaded operators * Expressions using array construction syntax * Expressions using selection or projection +* Expressions using bean references Compilation of additional kinds of expressions may be supported in the future. diff --git a/framework-docs/modules/ROOT/pages/core/expressions/example-classes.adoc b/framework-docs/modules/ROOT/pages/core/expressions/example-classes.adoc index b85702e3ae7b..2e63732cee1f 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/example-classes.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/example-classes.adoc @@ -9,7 +9,7 @@ This section lists the classes used in the examples throughout this chapter. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package org.spring.samples.spel.inventor; @@ -84,7 +84,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package org.spring.samples.spel.inventor @@ -103,7 +103,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package org.spring.samples.spel.inventor; @@ -141,7 +141,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package org.spring.samples.spel.inventor @@ -155,7 +155,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package org.spring.samples.spel.inventor; @@ -200,7 +200,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package org.spring.samples.spel.inventor diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/array-construction.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/array-construction.adoc index 4bc931742295..aad70436210d 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/array-construction.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/array-construction.adoc @@ -8,7 +8,7 @@ to have the array populated at construction time. The following example shows ho ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- int[] numbers1 = (int[]) parser.parseExpression("new int[4]").getValue(context); @@ -21,7 +21,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val numbers1 = parser.parseExpression("new int[4]").getValue(context) as IntArray diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/bean-references.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/bean-references.adoc index 82e68876b1f0..1102db79deba 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/bean-references.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/bean-references.adoc @@ -1,65 +1,73 @@ [[expressions-bean-references]] = Bean References -If the evaluation context has been configured with a bean resolver, you can -look up beans from an expression by using the `@` symbol. The following example shows how +If the evaluation context has been configured with a bean resolver, you can look up beans +from an expression by using the `@` symbol as a prefix. The following example shows how to do so: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext context = new StandardEvaluationContext(); context.setBeanResolver(new MyBeanResolver()); - // This will end up calling resolve(context,"something") on MyBeanResolver during evaluation - Object bean = parser.parseExpression("@something").getValue(context); + // This will end up calling resolve(context, "someBean") on MyBeanResolver + // during evaluation. + Object bean = parser.parseExpression("@someBean").getValue(context); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val context = StandardEvaluationContext() context.setBeanResolver(MyBeanResolver()) - // This will end up calling resolve(context,"something") on MyBeanResolver during evaluation - val bean = parser.parseExpression("@something").getValue(context) + // This will end up calling resolve(context, "someBean") on MyBeanResolver + // during evaluation. + val bean = parser.parseExpression("@someBean").getValue(context) ---- ====== -To access a factory bean itself, you should instead prefix the bean name with an `&` symbol. -The following example shows how to do so: +[NOTE] +==== +If a bean name contains a dot (`.`) or other special characters, you must provide the +name of the bean as a _string literal_ – for example, `@'order.service'`. +==== + +To access a factory bean itself, you should instead prefix the bean name with an `&` +symbol. The following example shows how to do so: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext context = new StandardEvaluationContext(); context.setBeanResolver(new MyBeanResolver()); - // This will end up calling resolve(context,"&foo") on MyBeanResolver during evaluation - Object bean = parser.parseExpression("&foo").getValue(context); + // This will end up calling resolve(context, "&someFactoryBean") on + // MyBeanResolver during evaluation. + Object factoryBean = parser.parseExpression("&someFactoryBean").getValue(context); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val context = StandardEvaluationContext() context.setBeanResolver(MyBeanResolver()) - // This will end up calling resolve(context,"&foo") on MyBeanResolver during evaluation - val bean = parser.parseExpression("&foo").getValue(context) + // This will end up calling resolve(context, "&someFactoryBean") on + // MyBeanResolver during evaluation. + val factoryBean = parser.parseExpression("&someFactoryBean").getValue(context) ---- ====== - - diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/collection-projection.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/collection-projection.adoc index 78a3ec21fe4f..2f4ad28fa10f 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/collection-projection.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/collection-projection.adoc @@ -11,7 +11,7 @@ list. The following example uses projection to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // evaluates to ["Smiljan", "Idvor"] List placesOfBirth = parser.parseExpression("members.![placeOfBirth.city]") @@ -20,7 +20,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // evaluates to ["Smiljan", "Idvor"] val placesOfBirth = parser.parseExpression("members.![placeOfBirth.city]") diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/collection-selection.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/collection-selection.adoc index b87bc1733413..5f66882d608f 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/collection-selection.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/collection-selection.adoc @@ -12,7 +12,7 @@ selection lets us easily get a list of Serbian inventors, as the following examp ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- List list = (List) parser.parseExpression( "members.?[nationality == 'Serbian']").getValue(societyContext); @@ -20,7 +20,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val list = parser.parseExpression( "members.?[nationality == 'Serbian']").getValue(societyContext) as List @@ -41,14 +41,14 @@ than 27: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Map newMap = parser.parseExpression("#map.?[value < 27]").getValue(Map.class); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val newMap = parser.parseExpression("#map.?[value < 27]").getValue() as Map ---- diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/constructors.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/constructors.adoc index a35513c9c1ec..4057f7943ac5 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/constructors.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/constructors.adoc @@ -3,39 +3,40 @@ You can invoke constructors by using the `new` operator. You should use the fully qualified class name for all types except those located in the `java.lang` package -(`Integer`, `Float`, `String`, and so on). The following example shows how to use the -`new` operator to invoke constructors: +(`Integer`, `Float`, `String`, and so on). +xref:core/expressions/language-ref/varargs.adoc[Varargs] are also supported. + +The following example shows how to use the `new` operator to invoke constructors. [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- - Inventor einstein = p.parseExpression( - "new org.spring.samples.spel.inventor.Inventor('Albert Einstein', 'German')") + Inventor einstein = parser.parseExpression( + "new org.spring.samples.spel.inventor.Inventor('Albert Einstein', 'German')") .getValue(Inventor.class); // create new Inventor instance within the add() method of List - p.parseExpression( - "Members.add(new org.spring.samples.spel.inventor.Inventor( - 'Albert Einstein', 'German'))").getValue(societyContext); + parser.parseExpression( + "Members.add(new org.spring.samples.spel.inventor.Inventor('Albert Einstein', 'German'))") + .getValue(societyContext); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- - val einstein = p.parseExpression( - "new org.spring.samples.spel.inventor.Inventor('Albert Einstein', 'German')") + val einstein = parser.parseExpression( + "new org.spring.samples.spel.inventor.Inventor('Albert Einstein', 'German')") .getValue(Inventor::class.java) // create new Inventor instance within the add() method of List - p.parseExpression( - "Members.add(new org.spring.samples.spel.inventor.Inventor('Albert Einstein', 'German'))") + parser.parseExpression( + "Members.add(new org.spring.samples.spel.inventor.Inventor('Albert Einstein', 'German'))") .getValue(societyContext) ---- ====== - diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/functions.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/functions.adoc index 6e1e2bf81f64..00f3cb56fd35 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/functions.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/functions.adoc @@ -2,8 +2,12 @@ = Functions You can extend SpEL by registering user-defined functions that can be called within -expressions by using the `#functionName(...)` syntax. Functions can be registered as -variables in `EvaluationContext` implementations via the `setVariable()` method. +expressions by using the `#functionName(...)` syntax, and like with standard method +invocations, xref:core/expressions/language-ref/varargs.adoc[varargs] are also supported +for function invocations. + +Functions can be registered as _variables_ in `EvaluationContext` implementations via the +`setVariable()` method. [TIP] ==== @@ -26,7 +30,7 @@ reflection using a `java.lang.reflect.Method`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Method method = ...; @@ -36,7 +40,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val method: Method = ... @@ -51,7 +55,7 @@ For example, consider the following utility method that reverses a string: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public abstract class StringUtils { @@ -63,7 +67,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun reverseString(input: String): String { return StringBuilder(input).reverse().toString() @@ -77,7 +81,7 @@ You can register and use the preceding method, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); @@ -92,7 +96,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() @@ -110,8 +114,9 @@ potentially more efficient use cases if the `MethodHandle` target and parameters been fully bound prior to registration; however, partially bound handles are also supported. -Consider the `String#formatted(String, Object...)` instance method, which produces a -message according to a template and a variable number of arguments. +Consider the `String#formatted(Object...)` instance method, which produces a message +according to a template and a variable number of arguments +(xref:core/expressions/language-ref/varargs.adoc[varargs]). You can register and use the `formatted` method as a `MethodHandle`, as the following example shows: @@ -120,7 +125,7 @@ example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); @@ -136,7 +141,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val context = SimpleEvaluationContext.forReadOnlyDataBinding().build() @@ -151,16 +156,16 @@ Kotlin:: ---- ====== -As hinted above, binding a `MethodHandle` and registering the bound `MethodHandle` is also -supported. This is likely to be more performant if both the target and all the arguments -are bound. In that case no arguments are necessary in the SpEL expression, as the -following example shows: +As mentioned above, binding a `MethodHandle` and registering the bound `MethodHandle` is +also supported. This is likely to be more performant if both the target and all the +arguments are bound. In that case no arguments are necessary in the SpEL expression, as +the following example shows: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); @@ -168,9 +173,10 @@ Java:: String template = "This is a %s message with %s words: <%s>"; Object varargs = new Object[] { "prerecorded", 3, "Oh Hello World!", "ignored" }; MethodHandle mh = MethodHandles.lookup().findVirtual(String.class, "formatted", - MethodType.methodType(String.class, Object[].class)) + MethodType.methodType(String.class, Object[].class)) .bindTo(template) - .bindTo(varargs); //here we have to provide arguments in a single array binding + // Here we have to provide the arguments in a single array binding: + .bindTo(varargs); context.setVariable("message", mh); // evaluates to "This is a prerecorded message with 3 words: " @@ -180,7 +186,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val context = SimpleEvaluationContext.forReadOnlyDataBinding().build() @@ -189,9 +195,10 @@ Kotlin:: val varargs = arrayOf("prerecorded", 3, "Oh Hello World!", "ignored") val mh = MethodHandles.lookup().findVirtual(String::class.java, "formatted", - MethodType.methodType(String::class.java, Array::class.java)) + MethodType.methodType(String::class.java, Array::class.java)) .bindTo(template) - .bindTo(varargs) //here we have to provide arguments in a single array binding + // Here we have to provide the arguments in a single array binding: + .bindTo(varargs) context.setVariable("message", mh) // evaluates to "This is a prerecorded message with 3 words: " @@ -201,4 +208,3 @@ Kotlin:: ====== - diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/inline-lists.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/inline-lists.adoc index 463d54d80955..5bcea13768fa 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/inline-lists.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/inline-lists.adoc @@ -7,7 +7,7 @@ You can directly express lists in an expression by using `{}` notation. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // evaluates to a Java list containing the four numbers List numbers = (List) parser.parseExpression("{1,2,3,4}").getValue(context); @@ -17,7 +17,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // evaluates to a Java list containing the four numbers val numbers = parser.parseExpression("{1,2,3,4}").getValue(context) as List<*> diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/inline-maps.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/inline-maps.adoc index 2b972329cd8b..122b3d651202 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/inline-maps.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/inline-maps.adoc @@ -8,7 +8,7 @@ following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // evaluates to a Java map containing the two entries Map inventorInfo = (Map) parser.parseExpression("{name:'Nikola',dob:'10-July-1856'}").getValue(context); @@ -18,7 +18,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- // evaluates to a Java map containing the two entries val inventorInfo = parser.parseExpression("{name:'Nikola',dob:'10-July-1856'}").getValue(context) as Map<*, *> diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/literal.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/literal.adoc index 52b1c9c06ced..a191ed6f9354 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/literal.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/literal.adoc @@ -53,7 +53,7 @@ method. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); @@ -75,7 +75,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/methods.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/methods.adoc index 46c91b836254..6e6f26e1737b 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/methods.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/methods.adoc @@ -1,15 +1,17 @@ [[expressions-methods]] = Methods -You can invoke methods by using typical Java programming syntax. You can also invoke methods -on literals. Variable arguments are also supported. The following examples show how to -invoke methods: +You can invoke methods by using the typical Java programming syntax. You can also invoke +methods directly on literals such as strings or numbers. +xref:core/expressions/language-ref/varargs.adoc[Varargs] are supported as well. + +The following examples show how to invoke methods. [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // string literal, evaluates to "bc" String bc = parser.parseExpression("'abc'.substring(1, 3)").getValue(String.class); @@ -21,7 +23,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // string literal, evaluates to "bc" val bc = parser.parseExpression("'abc'.substring(1, 3)").getValue(String::class.java) diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-elvis.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-elvis.adoc index 3884dcadf2f7..5880f13f5d97 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-elvis.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-elvis.adoc @@ -19,7 +19,7 @@ The following example shows how to use the Elvis operator: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); @@ -29,7 +29,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() @@ -48,7 +48,7 @@ The following listing shows a more complex example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); @@ -64,7 +64,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val context = SimpleEvaluationContext.forReadOnlyDataBinding().build() diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-safe-navigation.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-safe-navigation.adoc index d914a7002965..60d406cb2963 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-safe-navigation.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-safe-navigation.adoc @@ -1,7 +1,7 @@ [[expressions-operator-safe-navigation]] = Safe Navigation Operator -The safe navigation operator (`?`) is used to avoid a `NullPointerException` and comes +The safe navigation operator (`?.`) is used to avoid a `NullPointerException` and comes from the https://www.groovy-lang.org/operators.html#_safe_navigation_operator[Groovy] language. Typically, when you have a reference to an object, you might need to verify that it is not `null` before accessing methods or properties of the object. To avoid @@ -27,7 +27,7 @@ The following example shows how to use the safe navigation operator for property ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); @@ -50,7 +50,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val context = SimpleEvaluationContext.forReadOnlyDataBinding().build() @@ -81,6 +81,65 @@ For example, the expression `#calculator?.max(4, 2)` evaluates to `null` if the `max(int, int)` method will be invoked on the `#calculator`. ==== +[[expressions-operator-safe-navigation-indexing]] +== Safe Index Access + +Since Spring Framework 6.2, the Spring Expression Language supports safe navigation for +indexing into the following types of structures. + +* xref:core/expressions/language-ref/properties-arrays.adoc#expressions-indexing-arrays-and-collections[arrays and collections] +* xref:core/expressions/language-ref/properties-arrays.adoc#expressions-indexing-strings[strings] +* xref:core/expressions/language-ref/properties-arrays.adoc#expressions-indexing-maps[maps] +* xref:core/expressions/language-ref/properties-arrays.adoc#expressions-indexing-objects[objects] +* xref:core/expressions/language-ref/properties-arrays.adoc#expressions-indexing-custom[custom] + +The following example shows how to use the safe navigation operator for indexing into +a list (`?.[]`). + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + ExpressionParser parser = new SpelExpressionParser(); + IEEE society = new IEEE(); + EvaluationContext context = new StandardEvaluationContext(society); + + // evaluates to Inventor("Nikola Tesla") + Inventor inventor = parser.parseExpression("members?.[0]") // <1> + .getValue(context, Inventor.class); + + society.members = null; + + // evaluates to null - does not throw an exception + inventor = parser.parseExpression("members?.[0]") // <2> + .getValue(context, Inventor.class); +---- +<1> Use null-safe index operator on a non-null `members` list +<2> Use null-safe index operator on a null `members` list + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + val parser = SpelExpressionParser() + val society = IEEE() + val context = StandardEvaluationContext(society) + + // evaluates to Inventor("Nikola Tesla") + var inventor = parser.parseExpression("members?.[0]") // <1> + .getValue(context, Inventor::class.java) + + society.members = null + + // evaluates to null - does not throw an exception + inventor = parser.parseExpression("members?.[0]") // <2> + .getValue(context, Inventor::class.java) +---- +<1> Use null-safe index operator on a non-null `members` list +<2> Use null-safe index operator on a null `members` list +====== [[expressions-operator-safe-navigation-selection-and-projection]] == Safe Collection Selection and Projection @@ -102,7 +161,7 @@ selection (`?.?`). ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); IEEE society = new IEEE(); @@ -123,7 +182,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val society = IEEE() @@ -150,7 +209,7 @@ collections (`?.^`). ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); IEEE society = new IEEE(); @@ -172,7 +231,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val society = IEEE() @@ -201,7 +260,7 @@ collections (`?.$`). ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); IEEE society = new IEEE(); @@ -223,7 +282,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val society = IEEE() @@ -251,7 +310,7 @@ projection (`?.!`). ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); IEEE society = new IEEE(); @@ -272,7 +331,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val society = IEEE() @@ -321,7 +380,7 @@ evaluates to `null` instead of throwing an exception. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); IEEE society = new IEEE(); @@ -342,7 +401,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val society = IEEE() diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-ternary.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-ternary.adoc index 0a834d195fd6..09defa169927 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-ternary.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-ternary.adoc @@ -8,7 +8,7 @@ the expression. The following listing shows a minimal example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- String falseString = parser.parseExpression( "false ? 'trueExp' : 'falseExp'").getValue(String.class); @@ -16,7 +16,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val falseString = parser.parseExpression( "false ? 'trueExp' : 'falseExp'").getValue(String::class.java) @@ -30,7 +30,7 @@ realistic example follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- parser.parseExpression("name").setValue(societyContext, "IEEE"); societyContext.setVariable("queryName", "Nikola Tesla"); @@ -45,7 +45,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- parser.parseExpression("name").setValue(societyContext, "IEEE") societyContext.setVariable("queryName", "Nikola Tesla") diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operators.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operators.adoc index f658d77d4410..92c0b3db563a 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operators.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operators.adoc @@ -24,7 +24,7 @@ The following listing shows a few examples of relational operators: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // evaluates to true boolean trueValue = parser.parseExpression("2 == 2").getValue(Boolean.class); @@ -41,7 +41,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // evaluates to true val trueValue = parser.parseExpression("2 == 2").getValue(Boolean::class.java) @@ -89,7 +89,7 @@ shows examples of all three: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- boolean result; @@ -128,7 +128,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // evaluates to true var result = parser.parseExpression( @@ -195,7 +195,7 @@ The following example shows how to use the logical operators: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // -- AND -- @@ -228,7 +228,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // -- AND -- @@ -277,7 +277,7 @@ The following example shows the `String` operators in use: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // -- Concatenation -- @@ -300,7 +300,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // -- Concatenation -- @@ -358,7 +358,7 @@ The following example shows the mathematical operators in use: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Inventor inventor = new Inventor(); EvaluationContext context = SimpleEvaluationContext.forReadWriteDataBinding().build(); @@ -424,7 +424,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val inventor = Inventor() val context = SimpleEvaluationContext.forReadWriteDataBinding().build() @@ -501,7 +501,7 @@ listing shows both ways to use the assignment operator: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Inventor inventor = new Inventor(); EvaluationContext context = SimpleEvaluationContext.forReadWriteDataBinding().build(); @@ -515,7 +515,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val inventor = Inventor() val context = SimpleEvaluationContext.forReadWriteDataBinding().build() @@ -541,7 +541,7 @@ For example, if we want to overload the `ADD` operator to allow two lists to be concatenated using the `+` sign, we can implement a custom `OperatorOverloader` as follows. -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- pubic class ListConcatenation implements OperatorOverloader { @@ -576,7 +576,7 @@ as demonstrated in the following example. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- StandardEvaluationContext context = new StandardEvaluationContext(); context.setOperatorOverloader(new ListConcatenation()); @@ -587,7 +587,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- StandardEvaluationContext context = StandardEvaluationContext() context.setOperatorOverloader(ListConcatenation()) diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/properties-arrays.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/properties-arrays.adoc index 3066b54dec7b..f00e2485c36a 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/properties-arrays.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/properties-arrays.adoc @@ -1,31 +1,47 @@ [[expressions-properties-arrays]] = Properties, Arrays, Lists, Maps, and Indexers -Navigating with property references is easy. To do so, use a period to indicate a nested -property value. The instances of the `Inventor` class, `pupin` and `tesla`, were -populated with data listed in the xref:core/expressions/example-classes.adoc[Classes used in the examples] - section. To navigate "down" the object graph and get Tesla's year of birth and -Pupin's city of birth, we use the following expressions: +The Spring Expression Language provides support for navigating object graphs and indexing +into various structures. + +NOTE: Numerical index values are zero-based, such as when accessing the n^th^ element of +an array in Java. + +TIP: See the xref:core/expressions/language-ref/operator-safe-navigation.adoc[Safe Navigation Operator] +section for details on how to navigate object graphs and index into various structures +using the null-safe operator. + +[[expressions-property-navigation]] +== Property Navigation + +You can navigate property references within an object graph by using a period to indicate +a nested property value. The instances of the `Inventor` class, `pupin` and `tesla`, were +populated with data listed in the +xref:core/expressions/example-classes.adoc[Classes used in the examples] section. To +navigate _down_ the object graph and get Tesla's year of birth and Pupin's city of birth, +we use the following expressions: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // evaluates to 1856 int year = (Integer) parser.parseExpression("birthdate.year + 1900").getValue(context); + // evaluates to "Smiljan" String city = (String) parser.parseExpression("placeOfBirth.city").getValue(context); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // evaluates to 1856 val year = parser.parseExpression("birthdate.year + 1900").getValue(context) as Int + // evaluates to "Smiljan" val city = parser.parseExpression("placeOfBirth.city").getValue(context) as String ---- ====== @@ -39,14 +55,26 @@ method invocations -- for example, `getPlaceOfBirth().getCity()` instead of `placeOfBirth.city`. ==== -The contents of arrays and lists are obtained by using square bracket notation, as the -following example shows: +[[expressions-indexing-arrays-and-collections]] +== Indexing into Arrays and Collections + +The n^th^ element of an array or collection (for example, a `Set` or `List`) can be +obtained by using square bracket notation, as the following example shows. + +[NOTE] +==== +If the indexed collection is a `java.util.List`, the n^th^ element will be accessed +directly via `list.get(n)`. + +For any other type of `Collection`, the n^th^ element will be accessed by iterating over +the collection using its `Iterator` and returning the n^th^ element encountered. +==== [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ExpressionParser parser = new SpelExpressionParser(); EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); @@ -63,7 +91,8 @@ Java:: String name = parser.parseExpression("members[0].name").getValue( context, ieee, String.class); - // List and Array navigation + // List and Array Indexing + // evaluates to "Wireless communication" String invention = parser.parseExpression("members[0].inventions[6]").getValue( context, ieee, String.class); @@ -71,7 +100,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parser = SpelExpressionParser() val context = SimpleEvaluationContext.forReadOnlyDataBinding().build() @@ -88,55 +117,248 @@ Kotlin:: val name = parser.parseExpression("members[0].name").getValue( context, ieee, String::class.java) - // List and Array navigation + // List and Array Indexing + // evaluates to "Wireless communication" val invention = parser.parseExpression("members[0].inventions[6]").getValue( context, ieee, String::class.java) ---- ====== -The contents of maps are obtained by specifying the literal key value within the -brackets. In the following example, because keys for the `officers` map are strings, we can specify -string literals: +[[expressions-indexing-strings]] +== Indexing into Strings + +The n^th^ character of a string can be obtained by specifying the index within square +brackets, as demonstrated in the following example. + +NOTE: The n^th^ character of a string will evaluate to a `java.lang.String`, not a +`java.lang.Character`. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + // evaluates to "T" (8th letter of "Nikola Tesla") + String character = parser.parseExpression("members[0].name[7]") + .getValue(societyContext, String.class); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + // evaluates to "T" (8th letter of "Nikola Tesla") + val character = parser.parseExpression("members[0].name[7]") + .getValue(societyContext, String::class.java) +---- +====== + +[[expressions-indexing-maps]] +== Indexing into Maps + +The contents of maps are obtained by specifying the key value within square brackets. In +the following example, because keys for the `officers` map are strings, we can specify +string literals such as `'president'`: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- - // Officer's Dictionary + // Officer's Map - Inventor pupin = parser.parseExpression("officers['president']").getValue( - societyContext, Inventor.class); + // evaluates to Inventor("Pupin") + Inventor pupin = parser.parseExpression("officers['president']") + .getValue(societyContext, Inventor.class); // evaluates to "Idvor" - String city = parser.parseExpression("officers['president'].placeOfBirth.city").getValue( - societyContext, String.class); + String city = parser.parseExpression("officers['president'].placeOfBirth.city") + .getValue(societyContext, String.class); + + String countryExpression = "officers['advisors'][0].placeOfBirth.country"; // setting values - parser.parseExpression("officers['advisors'][0].placeOfBirth.country").setValue( - societyContext, "Croatia"); + parser.parseExpression(countryExpression) + .setValue(societyContext, "Croatia"); + + // evaluates to "Croatia" + String country = parser.parseExpression(countryExpression) + .getValue(societyContext, String.class); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- - // Officer's Dictionary + // Officer's Map - val pupin = parser.parseExpression("officers['president']").getValue( - societyContext, Inventor::class.java) + // evaluates to Inventor("Pupin") + val pupin = parser.parseExpression("officers['president']") + .getValue(societyContext, Inventor::class.java) // evaluates to "Idvor" - val city = parser.parseExpression("officers['president'].placeOfBirth.city").getValue( - societyContext, String::class.java) + val city = parser.parseExpression("officers['president'].placeOfBirth.city") + .getValue(societyContext, String::class.java) + + val countryExpression = "officers['advisors'][0].placeOfBirth.country" // setting values - parser.parseExpression("officers['advisors'][0].placeOfBirth.country").setValue( - societyContext, "Croatia") + parser.parseExpression(countryExpression) + .setValue(societyContext, "Croatia") + + // evaluates to "Croatia" + val country = parser.parseExpression(countryExpression) + .getValue(societyContext, String::class.java) +---- +====== + +[[expressions-indexing-objects]] +== Indexing into Objects + +A property of an object can be obtained by specifying the name of the property within +square brackets. This is analogous to accessing the value of a map based on its key. The +following example demonstrates how to _index_ into an object to retrieve a specific +property. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + // Create an inventor to use as the root context object. + Inventor tesla = new Inventor("Nikola Tesla"); + + // evaluates to "Nikola Tesla" + String name = parser.parseExpression("#root['name']") + .getValue(context, tesla, String.class); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + // Create an inventor to use as the root context object. + val tesla = Inventor("Nikola Tesla") + + // evaluates to "Nikola Tesla" + val name = parser.parseExpression("#root['name']") + .getValue(context, tesla, String::class.java) +---- +====== + +[[expressions-indexing-custom]] +== Indexing into Custom Structures + +Since Spring Framework 6.2, the Spring Expression Language supports indexing into custom +structures by allowing developers to implement and register an `IndexAccessor` with the +`EvaluationContext`. If you would like to support +xref:core/expressions/evaluation.adoc#expressions-spel-compilation[compilation] of +expressions that rely on a custom index accessor, that index accessor must implement the +`CompilableIndexAccessor` SPI. + +To support common use cases, Spring provides a built-in `ReflectiveIndexAccessor` which +is a flexible `IndexAccessor` that uses reflection to read from and optionally write to +an indexed structure of a target object. The indexed structure can be accessed through a +`public` read-method (when being read) or a `public` write-method (when being written). +The relationship between the read-method and write-method is based on a convention that +is applicable for typical implementations of indexed structures. + +NOTE: `ReflectiveIndexAccessor` also implements `CompilableIndexAccessor` in order to +support xref:core/expressions/evaluation.adoc#expressions-spel-compilation[compilation] +to bytecode for read access. Note, however, that the configured read-method must be +invokable via a `public` class or `public` interface for compilation to succeed. + +The following code listings define a `Color` enum and `FruitMap` type that behaves like a +map but does not implement the `java.util.Map` interface. Thus, if you want to index into +a `FruitMap` within a SpEL expression, you will need to register an `IndexAccessor`. + +[source,java,indent=0,subs="verbatim,quotes"] +---- + package example; + + public enum Color { + RED, ORANGE, YELLOW + } +---- + +[source,java,indent=0,subs="verbatim,quotes"] +---- + public class FruitMap { + + private final Map map = new HashMap<>(); + + public FruitMap() { + this.map.put(Color.RED, "cherry"); + this.map.put(Color.ORANGE, "orange"); + this.map.put(Color.YELLOW, "banana"); + } + + public String getFruit(Color color) { + return this.map.get(color); + } + + public void setFruit(Color color, String fruit) { + this.map.put(color, fruit); + } + } ---- + +A read-only `IndexAccessor` for `FruitMap` can be created via `new +ReflectiveIndexAccessor(FruitMap.class, Color.class, "getFruit")`. With that accessor +registered and a `FruitMap` registered as a variable named `#fruitMap`, the SpEL +expression `#fruitMap[T(example.Color).RED]` will evaluate to `"cherry"`. + +A read-write `IndexAccessor` for `FruitMap` can be created via `new +ReflectiveIndexAccessor(FruitMap.class, Color.class, "getFruit", "setFruit")`. With that +accessor registered and a `FruitMap` registered as a variable named `#fruitMap`, the SpEL +expression `#fruitMap[T(example.Color).RED] = 'strawberry'` can be used to change the +fruit mapping for the color red from `"cherry"` to `"strawberry"`. + +The following example demonstrates how to register a `ReflectiveIndexAccessor` to index +into a `FruitMap` and then index into the `FruitMap` within a SpEL expression. + +[tabs] ====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + // Create a ReflectiveIndexAccessor for FruitMap + IndexAccessor fruitMapAccessor = new ReflectiveIndexAccessor( + FruitMap.class, Color.class, "getFruit", "setFruit"); + // Register the IndexAccessor for FruitMap + context.addIndexAccessor(fruitMapAccessor); + // Register the fruitMap variable + context.setVariable("fruitMap", new FruitMap()); + + // evaluates to "cherry" + String fruit = parser.parseExpression("#fruitMap[T(example.Color).RED]") + .getValue(context, String.class); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + // Create a ReflectiveIndexAccessor for FruitMap + val fruitMapAccessor = ReflectiveIndexAccessor( + FruitMap::class.java, Color::class.java, "getFruit", "setFruit") + + // Register the IndexAccessor for FruitMap + context.addIndexAccessor(fruitMapAccessor) + + // Register the fruitMap variable + context.setVariable("fruitMap", FruitMap()) + + // evaluates to "cherry" + val fruit = parser.parseExpression("#fruitMap[T(example.Color).RED]") + .getValue(context, String::class.java) +---- +====== diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/templating.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/templating.adoc index d8e8b39abbe0..6961adee6ba7 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/templating.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/templating.adoc @@ -3,14 +3,14 @@ Expression templates allow mixing literal text with one or more evaluation blocks. Each evaluation block is delimited with prefix and suffix characters that you can -define. A common choice is to use `#{ }` as the delimiters, as the following example +define. A common choice is to use `+#{ }+` as the delimiters, as the following example shows: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- String randomPhrase = parser.parseExpression( "random number is #{T(java.lang.Math).random()}", @@ -21,7 +21,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val randomPhrase = parser.parseExpression( "random number is #{T(java.lang.Math).random()}", @@ -32,7 +32,7 @@ Kotlin:: ====== The string is evaluated by concatenating the literal text `'random number is '` with the -result of evaluating the expression inside the `#{ }` delimiters (in this case, the +result of evaluating the expression inside the `+#{ }+` delimiters (in this case, the result of calling that `random()` method). The second argument to the `parseExpression()` method is of the type `ParserContext`. The `ParserContext` interface is used to influence how the expression is parsed in order to support the expression templating functionality. diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/types.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/types.adoc index 3d501f0de670..0068ebab819b 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/types.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/types.adoc @@ -13,7 +13,7 @@ following example shows how to use the `T` operator: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Class dateClass = parser.parseExpression("T(java.util.Date)").getValue(Class.class); @@ -26,7 +26,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val dateClass = parser.parseExpression("T(java.util.Date)").getValue(Class::class.java) diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/varargs.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/varargs.adoc new file mode 100644 index 000000000000..8b7240a13e71 --- /dev/null +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/varargs.adoc @@ -0,0 +1,151 @@ +[[expressions-varargs]] += Varargs Invocations + +The Spring Expression Language supports +https://docs.oracle.com/javase/8/docs/technotes/guides/language/varargs.html[varargs] +invocations for xref:core/expressions/language-ref/constructors.adoc[constructors], +xref:core/expressions/language-ref/methods.adoc[methods], and user-defined +xref:core/expressions/language-ref/functions.adoc[functions]. + +The following example shows how to invoke the `java.lang.String#formatted(Object...)` +_varargs_ method within an expression by supplying the variable argument list as separate +arguments (`'blue', 1`). + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + // evaluates to "blue is color #1" + String expression = "'%s is color #%d'.formatted('blue', 1)"; + String message = parser.parseExpression(expression).getValue(String.class); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + // evaluates to "blue is color #1" + val expression = "'%s is color #%d'.formatted('blue', 1)" + val message = parser.parseExpression(expression).getValue(String::class.java) +---- +====== + +A variable argument list can also be supplied as an array, as demonstrated in the +following example (`new Object[] {'blue', 1}`). + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + // evaluates to "blue is color #1" + String expression = "'%s is color #%d'.formatted(new Object[] {'blue', 1})"; + String message = parser.parseExpression(expression).getValue(String.class); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + // evaluates to "blue is color #1" + val expression = "'%s is color #%d'.formatted(new Object[] {'blue', 1})" + val message = parser.parseExpression(expression).getValue(String::class.java) +---- +====== + +As an alternative, a variable argument list can be supplied as a `java.util.List` – for +example, as an xref:core/expressions/language-ref/inline-lists.adoc[inline list] +(`{'blue', 1}`). The following example shows how to do that. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + // evaluates to "blue is color #1" + String expression = "'%s is color #%d'.formatted({'blue', 1})"; + String message = parser.parseExpression(expression).getValue(String.class); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + // evaluates to "blue is color #1" + val expression = "'%s is color #%d'.formatted({'blue', 1})" + val message = parser.parseExpression(expression).getValue(String::class.java) +---- +====== + +[[expressions-varargs-type-conversion]] +== Varargs Type Conversion + +In contrast to the standard support for varargs invocations in Java, +xref:core/expressions/evaluation.adoc#expressions-type-conversion[type conversion] may be +applied to the individual arguments when invoking varargs constructors, methods, or +functions in SpEL. + +For example, if we have registered a custom +xref:core/expressions/language-ref/functions.adoc[function] in the `EvaluationContext` +under the name `#reverseStrings` for a method with the signature +`String reverseStrings(String... strings)`, we can invoke that function within a SpEL +expression with any argument that can be converted to a `String`, as demonstrated in the +following example. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + // evaluates to "3.0, 2.0, 1, SpEL" + String expression = "#reverseStrings('SpEL', 1, 10F / 5, 3.0000)"; + String message = parser.parseExpression(expression) + .getValue(evaluationContext, String.class); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + // evaluates to "3.0, 2.0, 1, SpEL" + val expression = "#reverseStrings('SpEL', 1, 10F / 5, 3.0000)" + val message = parser.parseExpression(expression) + .getValue(evaluationContext, String::class.java) +---- +====== + +Similarly, any array whose component type is a subtype of the required varargs type can +be supplied as the variable argument list for a varargs invocation. For example, a +`String[]` array can be supplied to a varargs invocation that accepts an `Object...` +argument list. + +The following listing demonstrates that we can supply a `String[]` array to the +`java.lang.String#formatted(Object...)` _varargs_ method. It also highlights that `1` +will be automatically converted to `"1"`. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + // evaluates to "blue is color #1" + String expression = "'%s is color #%s'.formatted(new String[] {'blue', 1})"; + String message = parser.parseExpression(expression).getValue(String.class); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + // evaluates to "blue is color #1" + val expression = "'%s is color #%s'.formatted(new String[] {'blue', 1})" + val message = parser.parseExpression(expression).getValue(String::class.java) +---- +====== + diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/variables.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/variables.adoc index ff285e28b584..70da818247c5 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/variables.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/variables.adoc @@ -42,7 +42,7 @@ The following example shows how to use variables. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Inventor tesla = new Inventor("Nikola Tesla", "Serbian"); @@ -55,7 +55,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val tesla = Inventor("Nikola Tesla", "Serbian") @@ -83,7 +83,7 @@ xref:core/expressions/language-ref/collection-selection.adoc[collection selectio ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // Create a list of prime integers. List primes = List.of(2, 3, 5, 7, 11, 13, 17); @@ -103,7 +103,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Create a list of prime integers. val primes = listOf(2, 3, 5, 7, 11, 13, 17) @@ -130,7 +130,7 @@ xref:core/expressions/language-ref/collection-projection.adoc[collection project ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // Create parser and evaluation context. ExpressionParser parser = new SpelExpressionParser(); @@ -154,7 +154,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Create parser and evaluation context. val parser = SpelExpressionParser() diff --git a/framework-docs/modules/ROOT/pages/core/resources.adoc b/framework-docs/modules/ROOT/pages/core/resources.adoc index 5fd279ab5c66..dee9df4f1abe 100644 --- a/framework-docs/modules/ROOT/pages/core/resources.adoc +++ b/framework-docs/modules/ROOT/pages/core/resources.adoc @@ -280,14 +280,14 @@ snippet of code was run against a `ClassPathXmlApplicationContext` instance: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Resource template = ctx.getResource("some/resource/path/myTemplate.txt"); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val template = ctx.getResource("some/resource/path/myTemplate.txt") ---- @@ -309,14 +309,14 @@ example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Resource template = ctx.getResource("classpath:some/resource/path/myTemplate.txt"); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val template = ctx.getResource("classpath:some/resource/path/myTemplate.txt") ---- @@ -329,14 +329,14 @@ Similarly, you can force a `UrlResource` to be used by specifying any of the sta ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Resource template = ctx.getResource("file:///some/resource/path/myTemplate.txt"); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val template = ctx.getResource("file:///some/resource/path/myTemplate.txt") ---- @@ -346,14 +346,14 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Resource template = ctx.getResource("https://myhost.com/resource/path/myTemplate.txt"); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val template = ctx.getResource("https://myhost.com/resource/path/myTemplate.txt") ---- @@ -505,7 +505,7 @@ property of type `Resource`. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- package example; @@ -523,7 +523,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MyBean(var template: Resource) ---- @@ -571,7 +571,7 @@ The following example demonstrates how to achieve this. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component public class MyBean { @@ -588,7 +588,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component class MyBean(@Value("\${template.path}") private val template: Resource) @@ -606,7 +606,7 @@ can be injected into the `MyBean` constructor. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component public class MyBean { @@ -623,7 +623,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component class MyBean(@Value("\${templates.path}") private val templates: Resource[]) @@ -657,14 +657,14 @@ specific application context. For example, consider the following example, which ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ApplicationContext ctx = new ClassPathXmlApplicationContext("conf/appContext.xml"); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val ctx = ClassPathXmlApplicationContext("conf/appContext.xml") ---- @@ -677,7 +677,7 @@ used. However, consider the following example, which creates a `FileSystemXmlApp ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ApplicationContext ctx = new FileSystemXmlApplicationContext("conf/appContext.xml"); @@ -685,7 +685,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val ctx = FileSystemXmlApplicationContext("conf/appContext.xml") ---- @@ -702,7 +702,7 @@ definitions. Consider the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ApplicationContext ctx = new FileSystemXmlApplicationContext("classpath:conf/appContext.xml"); @@ -710,7 +710,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val ctx = FileSystemXmlApplicationContext("classpath:conf/appContext.xml") ---- @@ -749,7 +749,7 @@ classpath) can be instantiated: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ApplicationContext ctx = new ClassPathXmlApplicationContext( new String[] {"services.xml", "repositories.xml"}, MessengerService.class); @@ -757,7 +757,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val ctx = ClassPathXmlApplicationContext(arrayOf("services.xml", "repositories.xml"), MessengerService::class.java) ---- @@ -843,7 +843,7 @@ special `classpath*:` prefix, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ApplicationContext ctx = new ClassPathXmlApplicationContext("classpath*:conf/appContext.xml"); @@ -851,7 +851,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val ctx = ClassPathXmlApplicationContext("classpath*:conf/appContext.xml") ---- @@ -905,8 +905,8 @@ policies in some environments -- for example, stand-alone applications on JDK 1. and higher (which requires 'Trusted-Library' to be set up in your manifests. See {stackoverflow-questions}/19394570/java-jre-7u45-breaks-classloader-getresources). -On JDK 9's module path (Jigsaw), Spring's classpath scanning generally works as expected. -Putting resources into a dedicated directory is highly recommendable here as well, +On the module path (Java Module System), Spring's classpath scanning generally works as +expected. Putting resources into a dedicated directory is highly recommendable here as well, avoiding the aforementioned portability problems with searching the jar file root level. ==== @@ -955,7 +955,7 @@ In practice, this means the following examples are equivalent: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ApplicationContext ctx = new FileSystemXmlApplicationContext("conf/context.xml"); @@ -963,7 +963,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val ctx = FileSystemXmlApplicationContext("conf/context.xml") ---- @@ -973,7 +973,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ApplicationContext ctx = new FileSystemXmlApplicationContext("/conf/context.xml"); @@ -981,7 +981,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val ctx = FileSystemXmlApplicationContext("/conf/context.xml") ---- @@ -994,7 +994,7 @@ case is relative and the other absolute): ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- FileSystemXmlApplicationContext ctx = ...; ctx.getResource("some/resource/path/myTemplate.txt"); @@ -1002,7 +1002,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val ctx: FileSystemXmlApplicationContext = ... ctx.getResource("some/resource/path/myTemplate.txt") @@ -1013,7 +1013,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- FileSystemXmlApplicationContext ctx = ...; ctx.getResource("/some/resource/path/myTemplate.txt"); @@ -1021,7 +1021,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val ctx: FileSystemXmlApplicationContext = ... ctx.getResource("/some/resource/path/myTemplate.txt") @@ -1037,7 +1037,7 @@ show how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // actual context type doesn't matter, the Resource will always be UrlResource ctx.getResource("file:///some/resource/path/myTemplate.txt"); @@ -1045,7 +1045,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // actual context type doesn't matter, the Resource will always be UrlResource ctx.getResource("file:///some/resource/path/myTemplate.txt") @@ -1056,7 +1056,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // force this FileSystemXmlApplicationContext to load its definition via a UrlResource ApplicationContext ctx = @@ -1065,7 +1065,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // force this FileSystemXmlApplicationContext to load its definition via a UrlResource val ctx = FileSystemXmlApplicationContext("file:///conf/context.xml") diff --git a/framework-docs/modules/ROOT/pages/core/spring-jcl.adoc b/framework-docs/modules/ROOT/pages/core/spring-jcl.adoc index 67e9d1d31756..547b80ddd435 100644 --- a/framework-docs/modules/ROOT/pages/core/spring-jcl.adoc +++ b/framework-docs/modules/ROOT/pages/core/spring-jcl.adoc @@ -1,7 +1,7 @@ [[spring-jcl]] = Logging -Since Spring Framework 5.0, Spring comes with its own Commons Logging bridge implemented +Spring comes with its own Commons Logging bridge implemented in the `spring-jcl` module. The implementation checks for the presence of the Log4j 2.x API and the SLF4J 1.7 API in the classpath and uses the first one of those found as the logging implementation, falling back to the Java platform's core logging facilities (also @@ -9,7 +9,7 @@ known as _JUL_ or `java.util.logging`) if neither Log4j 2.x nor SLF4J is availab Put Log4j 2.x or Logback (or another SLF4J provider) in your classpath, without any extra bridges, and let the framework auto-adapt to your choice. For further information see the -{spring-boot-docs}/features.html#features.logging[Spring +{spring-boot-docs-ref}/features/logging.html[Spring Boot Logging Reference Documentation]. [NOTE] @@ -27,7 +27,7 @@ the following example. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MyBean { private final Log log = LogFactory.getLog(getClass()); @@ -37,7 +37,7 @@ public class MyBean { Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MyBean { private val log = LogFactory.getLog(javaClass) diff --git a/framework-docs/modules/ROOT/pages/core/validation/beans-beans.adoc b/framework-docs/modules/ROOT/pages/core/validation/beans-beans.adoc index a68dbf43f8ac..d522a57c990e 100644 --- a/framework-docs/modules/ROOT/pages/core/validation/beans-beans.adoc +++ b/framework-docs/modules/ROOT/pages/core/validation/beans-beans.adoc @@ -27,15 +27,20 @@ The target class should have a single public constructor or a single non-public with arguments. If there are multiple constructors, then a default constructor if present is used. -By default, constructor parameter names are used to look up argument values, but you can -configure a `NameResolver`. Spring MVC and WebFlux both rely to allow customizing the name -of the value to bind through an `@BindParam` annotation on constructor parameters. +By default, argument values are looked up via constructor parameter names. Spring MVC and +WebFlux support a custom name mapping through the `@BindParam` annotation on constructor +parameters or fields if present. If necessary, you can also configure a `NameResolver` on +`DataBinder` to customize the argument name to use. xref:beans-beans-conventions[Type conversion] is applied as needed to convert user input. If the constructor parameter is an object, it is constructed recursively in the same manner, but through a nested property path. That means constructor binding creates both the target object and any objects it contains. +Constructor binding supports `List`, `Map`, and array arguments either converted from +a single string, for example, comma-separated list, or based on indexed keys such as +`accounts[2].name` or `account[KEY].name`. + Binding and conversion errors are reflected in the `BindingResult` of the `DataBinder`. If the target is created successfully, then `target` is set to the created instance after the call to `construct`. @@ -90,13 +95,12 @@ details. The below table shows some examples of these conventions: | Indicates the nested property `name` of the property `account` that corresponds to (for example) the `getAccount().setName()` or `getAccount().getName()` methods. -| `account[2]` +| `accounts[2]` | Indicates the _third_ element of the indexed property `account`. Indexed properties can be of type `array`, `list`, or other naturally ordered collection. -| `account[COMPANYNAME]` -| Indicates the value of the map entry indexed by the `COMPANYNAME` key of the `account` `Map` - property. +| `accounts[KEY]` +| Indicates the value of the map entry indexed by the `KEY` value. |=== (This next section is not vitally important to you if you do not plan to work with @@ -111,7 +115,7 @@ properties: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class Company { @@ -138,7 +142,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class Company { var name: String? = null @@ -151,7 +155,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class Employee { @@ -179,7 +183,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class Employee { var name: String? = null @@ -195,7 +199,7 @@ the properties of instantiated ``Company``s and ``Employee``s: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- BeanWrapper company = new BeanWrapperImpl(new Company()); // setting the company name.. @@ -215,7 +219,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val company = BeanWrapperImpl(Company()) // setting the company name.. @@ -375,7 +379,7 @@ associates a `CustomNumberEditor` with the `age` property of the `Something` cla ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SomethingBeanInfo extends SimpleBeanInfo { @@ -399,7 +403,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class SomethingBeanInfo : SimpleBeanInfo() { @@ -463,7 +467,7 @@ another class called `DependsOnExoticType`, which needs `ExoticType` set as a pr ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package example; @@ -488,7 +492,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package example @@ -518,7 +522,7 @@ The `PropertyEditor` implementation could look similar to the following: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package example; @@ -535,7 +539,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package example @@ -589,7 +593,7 @@ The following example shows how to create your own `PropertyEditorRegistrar` imp ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo.editors.spring; @@ -607,7 +611,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package com.foo.editors.spring @@ -657,7 +661,7 @@ example uses a `PropertyEditorRegistrar` in the implementation of an `@InitBinde ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller public class RegisterUserController { @@ -679,7 +683,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller class RegisterUserController( diff --git a/framework-docs/modules/ROOT/pages/core/validation/beanvalidation.adoc b/framework-docs/modules/ROOT/pages/core/validation/beanvalidation.adoc index bb588fb98f11..5d087e564185 100644 --- a/framework-docs/modules/ROOT/pages/core/validation/beanvalidation.adoc +++ b/framework-docs/modules/ROOT/pages/core/validation/beanvalidation.adoc @@ -20,7 +20,7 @@ Consider the following example, which shows a simple `PersonForm` model with two ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class PersonForm { private String name; @@ -30,7 +30,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class PersonForm( private val name: String, @@ -45,7 +45,7 @@ Bean Validation lets you declare constraints as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class PersonForm { @@ -60,7 +60,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class PersonForm( @get:NotNull @get:Size(max=64) @@ -94,7 +94,7 @@ bean, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; @@ -110,7 +110,7 @@ Java:: XML:: + -[source,xml,indent=0,subs="verbatim,quotes",role="secondary"] +[source,xml,indent=0,subs="verbatim,quotes"] ---- @@ -134,7 +134,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import jakarta.validation.Validator; @@ -148,7 +148,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import jakarta.validation.Validator; @@ -171,7 +171,7 @@ For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import org.springframework.validation.Validator; @@ -185,7 +185,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.validation.Validator @@ -226,7 +226,7 @@ The following example shows a custom `@Constraint` declaration followed by an as ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Target({ElementType.METHOD, ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) @@ -237,7 +237,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Target(AnnotationTarget.FUNCTION, AnnotationTarget.FIELD) @Retention(AnnotationRetention.RUNTIME) @@ -250,7 +250,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import jakarta.validation.ConstraintValidator; @@ -265,7 +265,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import jakarta.validation.ConstraintValidator @@ -287,32 +287,7 @@ As the preceding example shows, a `ConstraintValidator` implementation can have You can integrate the method validation feature of Bean Validation into a Spring context through a `MethodValidationPostProcessor` bean definition: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - import org.springframework.validation.beanvalidation.MethodValidationPostProcessor; - - @Configuration - public class AppConfig { - - @Bean - public static MethodValidationPostProcessor validationPostProcessor() { - return new MethodValidationPostProcessor(); - } - } - ----- - -XML:: -+ -[source,xml,indent=0,subs="verbatim,quotes",role="secondary"] ----- - ----- -====== +include-code::./ApplicationConfiguration[tag=snippet,indent=0] To be eligible for Spring-driven method validation, target classes need to be annotated with Spring's `@Validated` annotation, which can optionally also declare the validation @@ -345,36 +320,7 @@ By default, `jakarta.validation.ConstraintViolationException` is raised with the you can have `MethodValidationException` raised instead with ``ConstraintViolation``s adapted to `MessageSourceResolvable` errors. To enable set the following flag: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - import org.springframework.validation.beanvalidation.MethodValidationPostProcessor; - - @Configuration - public class AppConfig { - - @Bean - public static MethodValidationPostProcessor validationPostProcessor() { - MethodValidationPostProcessor processor = new MethodValidationPostProcessor(); - processor.setAdaptConstraintViolations(true); - return processor; - } - } - ----- - -XML:: -+ -[source,xml,indent=0,subs="verbatim,quotes",role="secondary"] ----- - - - ----- -====== +include-code::./ApplicationConfiguration[tag=snippet,indent=0] `MethodValidationException` contains a list of ``ParameterValidationResult``s which group errors by method parameter, and each exposes a `MethodParameter`, the argument @@ -398,7 +344,7 @@ Given the following class declarations: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- record Person(@Size(min = 1, max = 10) String name) { } @@ -414,7 +360,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @JvmRecord internal data class Person(@Size(min = 1, max = 10) val name: String) @@ -438,12 +384,12 @@ A `ConstraintViolation` on `Person.name()` is adapted to a `FieldError` with the To customize the default message, you can add properties to xref:core/beans/context-introduction.adoc#context-functionality-messagesource[MessageSource] resource bundles using any of the above errors codes and message arguments. Note also that the -message argument `"name"` is itself a `MessagreSourceResolvable` with error codes -`"person.name"` and `"name"` and can customized too. For example: +message argument `"name"` is itself a `MessageSourceResolvable` with error codes +`"person.name"` and `"name"` and can be customized too. For example: Properties:: + -[source,properties,indent=0,subs="verbatim,quotes",role="secondary"] +[source,properties,indent=0,subs="verbatim,quotes"] ---- Size.person.name=Please, provide a {0} that is between {2} and {1} characters long person.name=username @@ -460,7 +406,7 @@ To customize the above default message, you can add a property such as: Properties:: + -[source,properties,indent=0,subs="verbatim,quotes",role="secondary"] +[source,properties,indent=0,subs="verbatim,quotes"] ---- Max.degrees=You cannot provide more than {1} {0} ---- @@ -491,7 +437,7 @@ logic after binding to a target object: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Foo target = new Foo(); DataBinder binder = new DataBinder(target); @@ -509,7 +455,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val target = Foo() val binder = DataBinder(target) diff --git a/framework-docs/modules/ROOT/pages/core/validation/convert.adoc b/framework-docs/modules/ROOT/pages/core/validation/convert.adoc index 8f0f15486d5d..adf928eef7a1 100644 --- a/framework-docs/modules/ROOT/pages/core/validation/convert.adoc +++ b/framework-docs/modules/ROOT/pages/core/validation/convert.adoc @@ -263,7 +263,7 @@ it like you would for any other bean. The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Service public class MyService { @@ -282,7 +282,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Service class MyService(private val conversionService: ConversionService) { @@ -306,7 +306,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- DefaultConversionService cs = new DefaultConversionService(); @@ -318,7 +318,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val cs = DefaultConversionService() diff --git a/framework-docs/modules/ROOT/pages/core/validation/format-configuring-formatting-globaldatetimeformat.adoc b/framework-docs/modules/ROOT/pages/core/validation/format-configuring-formatting-globaldatetimeformat.adoc index 82fc655f1310..1b67d8467a1c 100644 --- a/framework-docs/modules/ROOT/pages/core/validation/format-configuring-formatting-globaldatetimeformat.adoc +++ b/framework-docs/modules/ROOT/pages/core/validation/format-configuring-formatting-globaldatetimeformat.adoc @@ -11,106 +11,9 @@ formatters manually with the help of: * `org.springframework.format.datetime.standard.DateTimeFormatterRegistrar` * `org.springframework.format.datetime.DateFormatterRegistrar` -For example, the following Java configuration registers a global `yyyyMMdd` format: +For example, the following configuration registers a global `yyyyMMdd` format: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - public class AppConfig { - - @Bean - public FormattingConversionService conversionService() { - - // Use the DefaultFormattingConversionService but do not register defaults - DefaultFormattingConversionService conversionService = - new DefaultFormattingConversionService(false); - - // Ensure @NumberFormat is still supported - conversionService.addFormatterForFieldAnnotation( - new NumberFormatAnnotationFormatterFactory()); - - // Register JSR-310 date conversion with a specific global format - DateTimeFormatterRegistrar dateTimeRegistrar = new DateTimeFormatterRegistrar(); - dateTimeRegistrar.setDateFormatter(DateTimeFormatter.ofPattern("yyyyMMdd")); - dateTimeRegistrar.registerFormatters(conversionService); - - // Register date conversion with a specific global format - DateFormatterRegistrar dateRegistrar = new DateFormatterRegistrar(); - dateRegistrar.setFormatter(new DateFormatter("yyyyMMdd")); - dateRegistrar.registerFormatters(conversionService); - - return conversionService; - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - class AppConfig { - - @Bean - fun conversionService(): FormattingConversionService { - // Use the DefaultFormattingConversionService but do not register defaults - return DefaultFormattingConversionService(false).apply { - - // Ensure @NumberFormat is still supported - addFormatterForFieldAnnotation(NumberFormatAnnotationFormatterFactory()) - - // Register JSR-310 date conversion with a specific global format - val dateTimeRegistrar = DateTimeFormatterRegistrar() - dateTimeRegistrar.setDateFormatter(DateTimeFormatter.ofPattern("yyyyMMdd")) - dateTimeRegistrar.registerFormatters(this) - - // Register date conversion with a specific global format - val dateRegistrar = DateFormatterRegistrar() - dateRegistrar.setFormatter(DateFormatter("yyyyMMdd")) - dateRegistrar.registerFormatters(this) - } - } - } ----- -====== - -If you prefer XML-based configuration, you can use a -`FormattingConversionServiceFactoryBean`. The following example shows how to do so: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - - - - - - - - - - - - - - - ----- +include-code::./ApplicationConfiguration[tag=snippet,indent=0] Note there are extra considerations when configuring date and time formats in web applications. Please see diff --git a/framework-docs/modules/ROOT/pages/core/validation/format.adoc b/framework-docs/modules/ROOT/pages/core/validation/format.adoc index 1300bf3a5821..1d8dea34d2d6 100644 --- a/framework-docs/modules/ROOT/pages/core/validation/format.adoc +++ b/framework-docs/modules/ROOT/pages/core/validation/format.adoc @@ -74,7 +74,8 @@ The `format` subpackages provide several `Formatter` implementations as a conven The `number` package provides `NumberStyleFormatter`, `CurrencyStyleFormatter`, and `PercentStyleFormatter` to format `Number` objects that use a `java.text.NumberFormat`. The `datetime` package provides a `DateFormatter` to format `java.util.Date` objects with -a `java.text.DateFormat`. +a `java.text.DateFormat`, as well as a `DurationFormatter` to format `Duration` objects +in different styles defined in the `@DurationFormat.Style` enum (see <>). The following `DateFormatter` is an example `Formatter` implementation: @@ -82,7 +83,7 @@ The following `DateFormatter` is an example `Formatter` implementation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package org.springframework.format.datetime; @@ -118,7 +119,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- class DateFormatter(private val pattern: String) : Formatter { @@ -179,7 +180,7 @@ annotation to a formatter to let a number style or pattern be specified: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public final class NumberFormatAnnotationFormatterFactory implements AnnotationFormatterFactory { @@ -216,7 +217,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class NumberFormatAnnotationFormatterFactory : AnnotationFormatterFactory { @@ -255,7 +256,7 @@ example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MyModel { @@ -266,7 +267,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MyModel( @field:NumberFormat(style = Style.CURRENCY) private val decimal: BigDecimal @@ -280,9 +281,9 @@ Kotlin:: A portable format annotation API exists in the `org.springframework.format.annotation` package. You can use `@NumberFormat` to format `Number` fields such as `Double` and -`Long`, and `@DateTimeFormat` to format fields such as `java.util.Date`, -`java.util.Calendar`, and `Long` (for millisecond timestamps) as well as JSR-310 -`java.time` types. +`Long`, `@DurationFormat` to format `Duration` fields in ISO-8601 and simplified styles, +and `@DateTimeFormat` to format fields such as `java.util.Date`, `java.util.Calendar`, +and `Long` (for millisecond timestamps) as well as JSR-310 `java.time` types. The following example uses `@DateTimeFormat` to format a `java.util.Date` as an ISO date (yyyy-MM-dd): @@ -291,7 +292,7 @@ The following example uses `@DateTimeFormat` to format a `java.util.Date` as an ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MyModel { @@ -302,7 +303,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MyModel( @DateTimeFormat(iso=ISO.DATE) private val date: Date @@ -311,7 +312,8 @@ Kotlin:: ====== For further details, see the javadoc for -{spring-framework-api}/format/annotation/DateTimeFormat.html[`@DateTimeFormat`] and +{spring-framework-api}/format/annotation/DateTimeFormat.html[`@DateTimeFormat`], +{spring-framework-api}/format/annotation/DurationFormat.html[`@DurationFormat`], and {spring-framework-api}/format/annotation/NumberFormat.html[`@NumberFormat`]. [WARNING] @@ -339,7 +341,7 @@ page in the Spring Framework wiki. The `FormatterRegistry` is an SPI for registering formatters and converters. `FormattingConversionService` is an implementation of `FormatterRegistry` suitable for most environments. You can programmatically or declaratively configure this variant -as a Spring bean, e.g. by using `FormattingConversionServiceFactoryBean`. Because this +as a Spring bean, for example, by using `FormattingConversionServiceFactoryBean`. Because this implementation also implements `ConversionService`, you can directly configure it for use with Spring's `DataBinder` and the Spring Expression Language (SpEL). diff --git a/framework-docs/modules/ROOT/pages/core/validation/validator.adoc b/framework-docs/modules/ROOT/pages/core/validation/validator.adoc index 7fbbddb732d3..1d446814a10b 100644 --- a/framework-docs/modules/ROOT/pages/core/validation/validator.adoc +++ b/framework-docs/modules/ROOT/pages/core/validation/validator.adoc @@ -11,7 +11,7 @@ Consider the following example of a small data object: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class Person { @@ -24,7 +24,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class Person(val name: String, val age: Int) ---- @@ -45,7 +45,7 @@ example implements `Validator` for `Person` instances: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class PersonValidator implements Validator { @@ -70,7 +70,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class PersonValidator : Validator { @@ -114,7 +114,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class CustomerValidator implements Validator { @@ -155,7 +155,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class CustomerValidator(private val addressValidator: Validator) : Validator { @@ -200,7 +200,7 @@ not involving a binding process. As of 6.1, this has been simplified through a n `Validator.validateObject(Object)` method which is available by default now, returning a simple `Errors` representation which can be inspected: typically calling `hasErrors()` or the new `failOnError` method for turning the error summary message into an exception -(e.g. `validator.validateObject(myObject).failOnError(IllegalArgumentException::new)`). +(for example, `validator.validateObject(myObject).failOnError(IllegalArgumentException::new)`). diff --git a/framework-docs/modules/ROOT/pages/data-access/dao.adoc b/framework-docs/modules/ROOT/pages/data-access/dao.adoc index 9ca9666f5415..f4c1accbb007 100644 --- a/framework-docs/modules/ROOT/pages/data-access/dao.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/dao.adoc @@ -56,7 +56,7 @@ how to use the `@Repository` annotation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Repository // <1> public class SomeMovieFinder implements MovieFinder { @@ -67,7 +67,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Repository // <1> class SomeMovieFinder : MovieFinder { @@ -89,7 +89,7 @@ annotations. The following example works for a JPA repository: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Repository public class JpaMovieFinder implements MovieFinder { @@ -103,7 +103,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Repository class JpaMovieFinder : MovieFinder { @@ -124,7 +124,7 @@ example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Repository public class HibernateMovieFinder implements MovieFinder { @@ -142,7 +142,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Repository class HibernateMovieFinder(private val sessionFactory: SessionFactory) : MovieFinder { @@ -160,7 +160,7 @@ and other data access support classes (such as `SimpleJdbcCall` and others) by u ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Repository public class JdbcMovieFinder implements MovieFinder { @@ -178,7 +178,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Repository class JdbcMovieFinder(dataSource: DataSource) : MovieFinder { diff --git a/framework-docs/modules/ROOT/pages/data-access/jdbc/advanced.adoc b/framework-docs/modules/ROOT/pages/data-access/jdbc/advanced.adoc index 83be3072a8ef..91786b3dbdee 100644 --- a/framework-docs/modules/ROOT/pages/data-access/jdbc/advanced.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/jdbc/advanced.adoc @@ -21,7 +21,7 @@ and the entire list is used as the batch: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -53,7 +53,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { @@ -104,7 +104,7 @@ The following example shows a batch update using named parameters: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -126,7 +126,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { @@ -155,7 +155,7 @@ JDBC `?` placeholders: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -183,7 +183,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { @@ -223,7 +223,7 @@ As of 6.1.2, Spring bypasses the default `getParameterType` resolution on Postgr MS SQL Server. This is a common optimization to avoid further roundtrips to the DBMS just for parameter type resolution which is known to make a very significant difference on PostgreSQL and MS SQL Server specifically, in particular for batch operations. If you -happen to see a side effect e.g. when setting a byte array to null without specific type +happen to see a side effect, for example, when setting a byte array to null without specific type indication, you may explicitly set the `spring.jdbc.getParameterType.ignore=false` flag as a system property (see above) to restore full `getParameterType` resolution. @@ -254,7 +254,7 @@ The following example shows a batch update that uses a batch size of 100: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -283,7 +283,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { diff --git a/framework-docs/modules/ROOT/pages/data-access/jdbc/connections.adoc b/framework-docs/modules/ROOT/pages/data-access/jdbc/connections.adoc index a448b8121f44..d862ef883869 100644 --- a/framework-docs/modules/ROOT/pages/data-access/jdbc/connections.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/jdbc/connections.adoc @@ -46,47 +46,9 @@ To configure a `DriverManagerDataSource`: for the correct value.) . Provide a username and a password to connect to the database. -The following example shows how to configure a `DriverManagerDataSource` in Java: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - DriverManagerDataSource dataSource = new DriverManagerDataSource(); - dataSource.setDriverClassName("org.hsqldb.jdbcDriver"); - dataSource.setUrl("jdbc:hsqldb:hsql://localhost:"); - dataSource.setUsername("sa"); - dataSource.setPassword(""); ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - val dataSource = DriverManagerDataSource().apply { - setDriverClassName("org.hsqldb.jdbcDriver") - url = "jdbc:hsqldb:hsql://localhost:" - username = "sa" - password = "" - } ----- -====== - -The following example shows the corresponding XML configuration: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - ----- +The following example shows how to configure a `DriverManagerDataSource`: + +include-code::./DriverManagerDataSourceConfiguration[tag=snippet,indent=0] The next two examples show the basic connectivity and configuration for DBCP and C3P0. To learn about more options that help control the pooling features, see the product @@ -94,32 +56,11 @@ documentation for the respective connection pooling implementations. The following example shows DBCP configuration: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - ----- +include-code::./BasicDataSourceConfiguration[tag=snippet,indent=0] The following example shows C3P0 configuration: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - ----- - +include-code::./ComboPooledDataSourceConfiguration[tag=snippet,indent=0] [[jdbc-DataSourceUtils]] == Using `DataSourceUtils` @@ -236,7 +177,7 @@ corresponding `DataSource` proxy class for the target connection pool: see This is particularly useful for potentially empty transactions without actual statement execution (never fetching an actual resource in such a scenario), and also in front of a routing `DataSource` which means to take the transaction-synchronized read-only flag -and/or isolation level into account (e.g. `IsolationLevelDataSourceRouter`). +and/or isolation level into account (for example, `IsolationLevelDataSourceRouter`). `LazyConnectionDataSourceProxy` also provides special support for a read-only connection pool to use during a read-only transaction, avoiding the overhead of switching the JDBC diff --git a/framework-docs/modules/ROOT/pages/data-access/jdbc/core.adoc b/framework-docs/modules/ROOT/pages/data-access/jdbc/core.adoc index ce2aa6f47f72..7f4eaf6be896 100644 --- a/framework-docs/modules/ROOT/pages/data-access/jdbc/core.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/jdbc/core.adoc @@ -62,14 +62,14 @@ The following query gets the number of rows in a relation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- int rowCount = this.jdbcTemplate.queryForObject("select count(*) from t_actor", Integer.class); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val rowCount = jdbcTemplate.queryForObject("select count(*) from t_actor")!! ---- @@ -81,7 +81,7 @@ The following query uses a bind variable: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- int countOfActorsNamedJoe = this.jdbcTemplate.queryForObject( "select count(*) from t_actor where first_name = ?", Integer.class, "Joe"); @@ -89,7 +89,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val countOfActorsNamedJoe = jdbcTemplate.queryForObject( "select count(*) from t_actor where first_name = ?", arrayOf("Joe"))!! @@ -103,7 +103,7 @@ The following query looks for a `String`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- String lastName = this.jdbcTemplate.queryForObject( "select last_name from t_actor where id = ?", @@ -112,7 +112,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val lastName = this.jdbcTemplate.queryForObject( "select last_name from t_actor where id = ?", @@ -126,7 +126,7 @@ The following query finds and populates a single domain object: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Actor actor = jdbcTemplate.queryForObject( "select first_name, last_name from t_actor where id = ?", @@ -141,7 +141,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val actor = jdbcTemplate.queryForObject( "select first_name, last_name from t_actor where id = ?", @@ -157,7 +157,7 @@ The following query finds and populates a list of domain objects: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- List actors = this.jdbcTemplate.query( "select first_name, last_name from t_actor", @@ -171,7 +171,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val actors = jdbcTemplate.query("select first_name, last_name from t_actor") { rs, _ -> Actor(rs.getString("first_name"), rs.getString("last_name")) @@ -187,7 +187,7 @@ For example, it may be better to write the preceding code snippet as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- private final RowMapper actorRowMapper = (resultSet, rowNum) -> { Actor actor = new Actor(); @@ -203,7 +203,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val actorMapper = RowMapper { rs: ResultSet, rowNum: Int -> Actor(rs.getString("first_name"), rs.getString("last_name")) @@ -227,7 +227,7 @@ The following example inserts a new entry: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- this.jdbcTemplate.update( "insert into t_actor (first_name, last_name) values (?, ?)", @@ -236,7 +236,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- jdbcTemplate.update( "insert into t_actor (first_name, last_name) values (?, ?)", @@ -250,7 +250,7 @@ The following example updates an existing entry: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- this.jdbcTemplate.update( "update t_actor set last_name = ? where id = ?", @@ -259,7 +259,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- jdbcTemplate.update( "update t_actor set last_name = ? where id = ?", @@ -273,7 +273,7 @@ The following example deletes an entry: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- this.jdbcTemplate.update( "delete from t_actor where id = ?", @@ -282,7 +282,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- jdbcTemplate.update("delete from t_actor where id = ?", actorId.toLong()) ---- @@ -300,14 +300,14 @@ table: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- this.jdbcTemplate.execute("create table mytable (id integer, name varchar(100))"); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- jdbcTemplate.execute("create table mytable (id integer, name varchar(100))") ---- @@ -319,7 +319,7 @@ The following example invokes a stored procedure: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- this.jdbcTemplate.update( "call SUPPORT.REFRESH_ACTORS_SUMMARY(?)", @@ -328,7 +328,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- jdbcTemplate.update( "call SUPPORT.REFRESH_ACTORS_SUMMARY(?)", @@ -339,7 +339,7 @@ Kotlin:: More sophisticated stored procedure support is xref:data-access/jdbc/object.adoc#jdbc-StoredProcedure[covered later]. -[[jdbc-JdbcTemplate-idioms]] +[[jdbc-jdbctemplate-idioms]] === `JdbcTemplate` Best Practices Instances of the `JdbcTemplate` class are thread-safe, once configured. This is @@ -352,147 +352,23 @@ A common practice when using the `JdbcTemplate` class (and the associated xref:data-access/jdbc/core.adoc#jdbc-NamedParameterJdbcTemplate[`NamedParameterJdbcTemplate`] class) is to configure a `DataSource` in your Spring configuration file and then dependency-inject that shared `DataSource` bean into your DAO classes. The `JdbcTemplate` is created in -the setter for the `DataSource`. This leads to DAOs that resemble the following: +the setter for the `DataSource` or in the constructor. This leads to DAOs that resemble the following: --- -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - public class JdbcCorporateEventDao implements CorporateEventDao { +include-code::./JdbcCorporateEventDao[tag=snippet,indent=0] - private JdbcTemplate jdbcTemplate; - - public void setDataSource(DataSource dataSource) { - this.jdbcTemplate = new JdbcTemplate(dataSource); - } +The following example shows the corresponding configuration: - // JDBC-backed implementations of the methods on the CorporateEventDao follow... - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - class JdbcCorporateEventDao(dataSource: DataSource) : CorporateEventDao { - - private val jdbcTemplate = JdbcTemplate(dataSource) - - // JDBC-backed implementations of the methods on the CorporateEventDao follow... - } ----- -====== --- - -The following example shows the corresponding XML configuration: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - - - - - - - - - ----- +include-code::./JdbcCorporateEventDaoConfiguration[tag=snippet,indent=0] An alternative to explicit configuration is to use component-scanning and annotation support for dependency injection. In this case, you can annotate the class with `@Repository` -(which makes it a candidate for component-scanning) and annotate the `DataSource` setter -method with `@Autowired`. The following example shows how to do so: +(which makes it a candidate for component-scanning). The following example shows how to do so: --- -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Repository // <1> - public class JdbcCorporateEventDao implements CorporateEventDao { - - private JdbcTemplate jdbcTemplate; - - @Autowired // <2> - public void setDataSource(DataSource dataSource) { - this.jdbcTemplate = new JdbcTemplate(dataSource); // <3> - } - - // JDBC-backed implementations of the methods on the CorporateEventDao follow... - } ----- -<1> Annotate the class with `@Repository`. -<2> Annotate the `DataSource` setter method with `@Autowired`. -<3> Create a new `JdbcTemplate` with the `DataSource`. - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Repository // <1> - class JdbcCorporateEventDao(dataSource: DataSource) : CorporateEventDao { // <2> - - private val jdbcTemplate = JdbcTemplate(dataSource) // <3> - - // JDBC-backed implementations of the methods on the CorporateEventDao follow... - } ----- -<1> Annotate the class with `@Repository`. -<2> Constructor injection of the `DataSource`. -<3> Create a new `JdbcTemplate` with the `DataSource`. -====== --- - - -The following example shows the corresponding XML configuration: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - +include-code::./JdbcCorporateEventRepository[tag=snippet,indent=0] - - - - - - +The following example shows the corresponding configuration: - - - ----- +include-code::./JdbcCorporateEventRepositoryConfiguration[tag=snippet,indent=0] If you use Spring's `JdbcDaoSupport` class and your various JDBC-backed DAO classes extend from it, your sub-class inherits a `setDataSource(..)` method from the @@ -522,7 +398,7 @@ parameters. The following example shows how to use `NamedParameterJdbcTemplate`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // some JDBC-backed DAO class... private NamedParameterJdbcTemplate namedParameterJdbcTemplate; @@ -540,7 +416,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- private val namedParameterJdbcTemplate = NamedParameterJdbcTemplate(dataSource) @@ -567,7 +443,7 @@ The following example shows the use of the `Map`-based style: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // some JDBC-backed DAO class... private NamedParameterJdbcTemplate namedParameterJdbcTemplate; @@ -585,7 +461,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // some JDBC-backed DAO class... private val namedParameterJdbcTemplate = NamedParameterJdbcTemplate(dataSource) @@ -618,7 +494,7 @@ The following example shows a typical JavaBean: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class Actor { @@ -644,7 +520,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- data class Actor(val id: Long, val firstName: String, val lastName: String) ---- @@ -657,7 +533,7 @@ members of the class shown in the preceding example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // some JDBC-backed DAO class... private NamedParameterJdbcTemplate namedParameterJdbcTemplate; @@ -676,7 +552,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // some JDBC-backed DAO class... private val namedParameterJdbcTemplate = NamedParameterJdbcTemplate(dataSource) @@ -698,7 +574,7 @@ functionality that is present only in the `JdbcTemplate` class, you can use the `getJdbcOperations()` method to access the wrapped `JdbcTemplate` through the `JdbcOperations` interface. -See also xref:data-access/jdbc/core.adoc#jdbc-JdbcTemplate-idioms[`JdbcTemplate` Best Practices] +See also xref:data-access/jdbc/core.adoc#jdbc-jdbctemplate-idioms[`JdbcTemplate` Best Practices] for guidelines on using the `NamedParameterJdbcTemplate` class in the context of an application. @@ -870,7 +746,7 @@ You can extend `SQLErrorCodeSQLExceptionTranslator`, as the following example sh ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class CustomSQLErrorCodesTranslator extends SQLErrorCodeSQLExceptionTranslator { @@ -885,7 +761,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class CustomSQLErrorCodesTranslator : SQLErrorCodeSQLExceptionTranslator() { @@ -910,7 +786,7 @@ how you can use this custom translator: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- private JdbcTemplate jdbcTemplate; @@ -935,7 +811,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // create a JdbcTemplate and set data source private val jdbcTemplate = JdbcTemplate(dataSource).apply { @@ -970,7 +846,7 @@ fully functional class that creates a new table: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import javax.sql.DataSource; import org.springframework.jdbc.core.JdbcTemplate; @@ -991,7 +867,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import javax.sql.DataSource import org.springframework.jdbc.core.JdbcTemplate @@ -1021,7 +897,7 @@ query methods, one for an `int` and one that queries for a `String`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import javax.sql.DataSource; import org.springframework.jdbc.core.JdbcTemplate; @@ -1046,7 +922,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import javax.sql.DataSource import org.springframework.jdbc.core.JdbcTemplate @@ -1074,7 +950,7 @@ list of all the rows, it might be as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- private JdbcTemplate jdbcTemplate; @@ -1089,7 +965,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- private val jdbcTemplate = JdbcTemplate(dataSource) @@ -1116,7 +992,7 @@ The following example updates a column for a certain primary key: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import javax.sql.DataSource; import org.springframework.jdbc.core.JdbcTemplate; @@ -1137,7 +1013,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import javax.sql.DataSource import org.springframework.jdbc.core.JdbcTemplate @@ -1175,7 +1051,7 @@ on Oracle but may not work on other platforms: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- final String INSERT_SQL = "insert into my_test (name) values(?)"; final String name = "Rob"; @@ -1192,7 +1068,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val INSERT_SQL = "insert into my_test (name) values(?)" val name = "Rob" diff --git a/framework-docs/modules/ROOT/pages/data-access/jdbc/embedded-database-support.adoc b/framework-docs/modules/ROOT/pages/data-access/jdbc/embedded-database-support.adoc index f9855a33c84d..96a6023dac51 100644 --- a/framework-docs/modules/ROOT/pages/data-access/jdbc/embedded-database-support.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/jdbc/embedded-database-support.adoc @@ -16,124 +16,22 @@ lightweight nature. Benefits include ease of configuration, quick startup time, testability, and the ability to rapidly evolve your SQL during development. -[[jdbc-embedded-database-xml]] -== Creating an Embedded Database by Using Spring XML +[[jdbc-embedded-database]] +== Creating an Embedded Database -If you want to expose an embedded database instance as a bean in a Spring -`ApplicationContext`, you can use the `embedded-database` tag in the `spring-jdbc` namespace: +You can expose an embedded database instance as a bean as the following example shows: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - ----- +include-code::./JdbcEmbeddedDatabaseConfiguration[tag=snippet,indent=0] -The preceding configuration creates an embedded HSQL database that is populated with SQL from +The preceding configuration creates an embedded H2 database that is populated with SQL from the `schema.sql` and `test-data.sql` resources in the root of the classpath. In addition, as a best practice, the embedded database is assigned a uniquely generated name. The embedded database is made available to the Spring container as a bean of type `javax.sql.DataSource` that can then be injected into data access objects as needed. - -[[jdbc-embedded-database-java]] -== Creating an Embedded Database Programmatically - -The `EmbeddedDatabaseBuilder` class provides a fluent API for constructing an embedded -database programmatically. You can use this when you need to create an embedded database in a -stand-alone environment or in a stand-alone integration test, as in the following example: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - EmbeddedDatabase db = new EmbeddedDatabaseBuilder() - .generateUniqueName(true) - .setType(H2) - .setScriptEncoding("UTF-8") - .ignoreFailedDrops(true) - .addScript("schema.sql") - .addScripts("user_data.sql", "country_data.sql") - .build(); - - // perform actions against the db (EmbeddedDatabase extends javax.sql.DataSource) - - db.shutdown() ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - val db = EmbeddedDatabaseBuilder() - .generateUniqueName(true) - .setType(H2) - .setScriptEncoding("UTF-8") - .ignoreFailedDrops(true) - .addScript("schema.sql") - .addScripts("user_data.sql", "country_data.sql") - .build() - - // perform actions against the db (EmbeddedDatabase extends javax.sql.DataSource) - - db.shutdown() ----- -====== - See the {spring-framework-api}/jdbc/datasource/embedded/EmbeddedDatabaseBuilder.html[javadoc for `EmbeddedDatabaseBuilder`] for further details on all supported options. -You can also use the `EmbeddedDatabaseBuilder` to create an embedded database by using Java -configuration, as the following example shows: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - public class DataSourceConfig { - - @Bean - public DataSource dataSource() { - return new EmbeddedDatabaseBuilder() - .generateUniqueName(true) - .setType(H2) - .setScriptEncoding("UTF-8") - .ignoreFailedDrops(true) - .addScript("schema.sql") - .addScripts("user_data.sql", "country_data.sql") - .build(); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - class DataSourceConfig { - - @Bean - fun dataSource(): DataSource { - return EmbeddedDatabaseBuilder() - .generateUniqueName(true) - .setType(H2) - .setScriptEncoding("UTF-8") - .ignoreFailedDrops(true) - .addScript("schema.sql") - .addScripts("user_data.sql", "country_data.sql") - .build() - } - } ----- -====== - [[jdbc-embedded-database-types]] == Selecting the Embedded Database Type @@ -168,6 +66,74 @@ attribute of the `embedded-database` tag to `DERBY`. If you use the builder API, call the `setType(EmbeddedDatabaseType)` method with `EmbeddedDatabaseType.DERBY`. +[[jdbc-embedded-database-types-custom]] +== Customizing the Embedded Database Type + +While each supported type comes with default connection settings, it is possible +to customize them if necessary. The following example uses H2 with a custom driver: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @Configuration + public class DataSourceConfig { + + @Bean + public DataSource dataSource() { + return new EmbeddedDatabaseBuilder() + .setDatabaseConfigurer(EmbeddedDatabaseConfigurers + .customizeConfigurer(H2, this::customize)) + .addScript("schema.sql") + .build(); + } + + private EmbeddedDatabaseConfigurer customize(EmbeddedDatabaseConfigurer defaultConfigurer) { + return new EmbeddedDatabaseConfigurerDelegate(defaultConfigurer) { + @Override + public void configureConnectionProperties(ConnectionProperties properties, String databaseName) { + super.configureConnectionProperties(properties, databaseName); + properties.setDriverClass(CustomDriver.class); + } + }; + } + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @Configuration + class DataSourceConfig { + + @Bean + fun dataSource(): DataSource { + return EmbeddedDatabaseBuilder() + .setDatabaseConfigurer(EmbeddedDatabaseConfigurers + .customizeConfigurer(EmbeddedDatabaseType.H2) { this.customize(it) }) + .addScript("schema.sql") + .build() + } + + private fun customize(defaultConfigurer: EmbeddedDatabaseConfigurer): EmbeddedDatabaseConfigurer { + return object : EmbeddedDatabaseConfigurerDelegate(defaultConfigurer) { + override fun configureConnectionProperties( + properties: ConnectionProperties, + databaseName: String + ) { + super.configureConnectionProperties(properties, databaseName) + properties.setDriverClass(CustomDriver::class.java) + } + } + } + } +---- +====== + + [[jdbc-embedded-database-dao-testing]] == Testing Data Access Logic with an Embedded Database @@ -177,14 +143,14 @@ can be useful for one-offs when the embedded database does not need to be reused classes. However, if you wish to create an embedded database that is shared within a test suite, consider using the xref:testing/testcontext-framework.adoc[Spring TestContext Framework] and configuring the embedded database as a bean in the Spring `ApplicationContext` as described -in xref:data-access/jdbc/embedded-database-support.adoc#jdbc-embedded-database-xml[Creating an Embedded Database by Using Spring XML] and xref:data-access/jdbc/embedded-database-support.adoc#jdbc-embedded-database-java[Creating an Embedded Database Programmatically]. The following listing -shows the test template: +in xref:data-access/jdbc/embedded-database-support.adoc#jdbc-embedded-database[Creating an Embedded Database]. +The following listing shows the test template: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class DataAccessIntegrationTestTemplate { @@ -216,7 +182,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class DataAccessIntegrationTestTemplate { diff --git a/framework-docs/modules/ROOT/pages/data-access/jdbc/object.adoc b/framework-docs/modules/ROOT/pages/data-access/jdbc/object.adoc index 8941d958300f..dc3c135ae525 100644 --- a/framework-docs/modules/ROOT/pages/data-access/jdbc/object.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/jdbc/object.adoc @@ -44,7 +44,7 @@ data from the `t_actor` relation to an instance of the `Actor` class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ActorMappingQuery extends MappingSqlQuery { @@ -67,7 +67,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ActorMappingQuery(ds: DataSource) : MappingSqlQuery(ds, "select id, first_name, last_name from t_actor where id = ?") { @@ -103,7 +103,7 @@ example shows how to define such a class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- private ActorMappingQuery actorMappingQuery; @@ -119,7 +119,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- private val actorMappingQuery = ActorMappingQuery(dataSource) @@ -138,7 +138,7 @@ example shows such a method: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public List searchForActors(int age, String namePattern) { return actorSearchMappingQuery.execute(age, namePattern); @@ -147,7 +147,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun searchForActors(age: Int, namePattern: String) = actorSearchMappingQuery.execute(age, namePattern) @@ -171,7 +171,7 @@ The following example creates a custom update method named `execute`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import java.sql.Types; import javax.sql.DataSource; @@ -201,7 +201,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import java.sql.Types import javax.sql.DataSource @@ -247,7 +247,7 @@ as the following code snippet shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- new SqlParameter("in_id", Types.NUMERIC), new SqlOutParameter("out_first_name", Types.VARCHAR), @@ -255,7 +255,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- SqlParameter("in_id", Types.NUMERIC), SqlOutParameter("out_first_name", Types.VARCHAR), @@ -293,7 +293,7 @@ The following listing shows our custom StoredProcedure class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import java.sql.Types; import java.util.Date; @@ -342,7 +342,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import java.sql.Types import java.util.Date @@ -387,7 +387,7 @@ Oracle REF cursors): ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import java.util.HashMap; import java.util.Map; @@ -416,7 +416,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import java.util.HashMap import javax.sql.DataSource @@ -456,7 +456,7 @@ the supplied `ResultSet`, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import java.sql.ResultSet; import java.sql.SQLException; @@ -476,7 +476,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import java.sql.ResultSet import com.foo.domain.Title @@ -497,7 +497,7 @@ the supplied `ResultSet`, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import java.sql.ResultSet; import java.sql.SQLException; @@ -514,7 +514,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import java.sql.ResultSet import com.foo.domain.Genre @@ -537,7 +537,7 @@ delegate to the untyped `execute(Map)` method in the superclass, as the followin ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import java.sql.Types; import java.util.Date; @@ -571,7 +571,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import java.sql.Types import java.util.Date diff --git a/framework-docs/modules/ROOT/pages/data-access/jdbc/parameter-handling.adoc b/framework-docs/modules/ROOT/pages/data-access/jdbc/parameter-handling.adoc index f7df5a755f5f..3a9edff0ccbd 100644 --- a/framework-docs/modules/ROOT/pages/data-access/jdbc/parameter-handling.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/jdbc/parameter-handling.adoc @@ -67,7 +67,7 @@ The following example shows how to create and insert a BLOB: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- final File blobIn = new File("spring2004.jpg"); final InputStream blobIs = new FileInputStream(blobIn); @@ -95,7 +95,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val blobIn = File("spring2004.jpg") val blobIs = FileInputStream(blobIn) @@ -142,7 +142,7 @@ The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- List> l = jdbcTemplate.query("select id, a_clob, a_blob from lob_table", new RowMapper>() { @@ -161,7 +161,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val l = jdbcTemplate.query("select id, a_clob, a_blob from lob_table") { rs, _ -> val clobText = lobHandler.getClobAsString(rs, "a_clob") // <1> diff --git a/framework-docs/modules/ROOT/pages/data-access/jdbc/simple.adoc b/framework-docs/modules/ROOT/pages/data-access/jdbc/simple.adoc index 177249aa3521..27c3e6898352 100644 --- a/framework-docs/modules/ROOT/pages/data-access/jdbc/simple.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/jdbc/simple.adoc @@ -23,7 +23,7 @@ example uses only one configuration method (we show examples of multiple methods ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -47,7 +47,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { @@ -85,7 +85,7 @@ listing shows how it works: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -111,7 +111,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { @@ -150,7 +150,7 @@ You can limit the columns for an insert by specifying a list of column names wit ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -177,7 +177,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { @@ -217,7 +217,7 @@ values. The following example shows how to use `BeanPropertySqlParameterSource`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -241,7 +241,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { @@ -267,7 +267,7 @@ convenient `addValue` method that can be chained. The following example shows ho ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -293,7 +293,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { @@ -359,7 +359,7 @@ of the stored procedure): ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -388,7 +388,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { @@ -437,7 +437,7 @@ the constructor of your `SimpleJdbcCall`. The following example shows this confi ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -456,7 +456,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { @@ -502,7 +502,7 @@ the preceding example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -529,7 +529,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { @@ -567,7 +567,7 @@ similar to the following: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- new SqlParameter("in_id", Types.NUMERIC), new SqlOutParameter("out_first_name", Types.VARCHAR), @@ -575,7 +575,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- SqlParameter("in_id", Types.NUMERIC), SqlOutParameter("out_first_name", Types.VARCHAR), @@ -636,7 +636,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -662,7 +662,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { @@ -720,7 +720,7 @@ The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class JdbcActorDao implements ActorDao { @@ -746,7 +746,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class JdbcActorDao(dataSource: DataSource) : ActorDao { diff --git a/framework-docs/modules/ROOT/pages/data-access/orm/general.adoc b/framework-docs/modules/ROOT/pages/data-access/orm/general.adoc index 3138409d36e3..bbbd6b8b4542 100644 --- a/framework-docs/modules/ROOT/pages/data-access/orm/general.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/orm/general.adoc @@ -65,7 +65,7 @@ examples (one for Java configuration and one for XML configuration) show how to ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Repository public class ProductDaoImpl implements ProductDao { @@ -77,7 +77,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Repository class ProductDaoImpl : ProductDao { diff --git a/framework-docs/modules/ROOT/pages/data-access/orm/hibernate.adoc b/framework-docs/modules/ROOT/pages/data-access/orm/hibernate.adoc index 45328b85cd86..149841aeb3e9 100644 --- a/framework-docs/modules/ROOT/pages/data-access/orm/hibernate.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/orm/hibernate.adoc @@ -87,8 +87,8 @@ On `LocalSessionFactoryBean`, this is available through the `bootstrapExecutor` property. On the programmatic `LocalSessionFactoryBuilder`, there is an overloaded `buildSessionFactory` method that takes a bootstrap executor argument. -As of Spring Framework 5.1, such a native Hibernate setup can also expose a JPA -`EntityManagerFactory` for standard JPA interaction next to native Hibernate access. +Such a native Hibernate setup can also expose a JPA `EntityManagerFactory` for standard +JPA interaction next to native Hibernate access. See xref:data-access/orm/jpa.adoc#orm-jpa-hibernate[Native Hibernate Setup for JPA] for details. ==== @@ -105,7 +105,7 @@ implementation resembles the following example, based on the plain Hibernate API ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ProductDaoImpl implements ProductDao { @@ -126,7 +126,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ProductDaoImpl(private val sessionFactory: SessionFactory) : ProductDao { @@ -208,7 +208,7 @@ these annotated methods. The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ProductServiceImpl implements ProductService { @@ -233,7 +233,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ProductServiceImpl(private val productDao: ProductDao) : ProductService { @@ -317,7 +317,7 @@ and an example for a business method implementation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ProductServiceImpl implements ProductService { @@ -345,7 +345,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ProductServiceImpl(transactionManager: PlatformTransactionManager, private val productDao: ProductDao) : ProductService { diff --git a/framework-docs/modules/ROOT/pages/data-access/orm/jpa.adoc b/framework-docs/modules/ROOT/pages/data-access/orm/jpa.adoc index b2400b9e16ec..011f1033ad0c 100644 --- a/framework-docs/modules/ROOT/pages/data-access/orm/jpa.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/orm/jpa.adoc @@ -142,8 +142,7 @@ Alternatively, specify a custom `persistenceXmlLocation` on your META-INF/my-persistence.xml) and include only a descriptor with that name in your application jar files. Because the Jakarta EE server looks only for default `META-INF/persistence.xml` files, it ignores such custom persistence units and, hence, -avoids conflicts with a Spring-driven JPA setup upfront. (This applies to Resin 3.1, for -example.) +avoids conflicts with a Spring-driven JPA setup upfront. .When is load-time weaving required? **** @@ -175,7 +174,7 @@ a context-wide `LoadTimeWeaver` by using the `@EnableLoadTimeWeaving` annotation `context:load-time-weaver` XML element. Such a global weaver is automatically picked up by all JPA `LocalContainerEntityManagerFactoryBean` instances. The following example shows the preferred way of setting up a load-time weaver, delivering auto-detection -of the platform (e.g. Tomcat's weaving-capable class loader or Spring's JVM agent) +of the platform (for example, Tomcat's weaving-capable class loader or Spring's JVM agent) and automatic propagation of the weaver to all weaver-aware beans: [source,xml,indent=0,subs="verbatim,quotes"] @@ -272,6 +271,13 @@ is being accessed by other components (for example, calling `createEntityManager calls block until the background bootstrapping has completed. In particular, when you use Spring Data JPA, make sure to set up deferred bootstrapping for its repositories as well. +As of 6.2, JPA initialization is enforced before context refresh completion, waiting for +asynchronous bootstrapping to complete by then. This makes the availability of the fully +initialized database infrastructure predictable and allows for custom post-initialization +logic in `ContextRefreshedEvent` listeners etc. Putting such application-level database +initialization into `@PostConstruct` methods or the like is not recommended; this is +better placed in `Lifecycle.start` (if applicable) or a `ContextRefreshedEvent` listener. + [[orm-jpa-dao]] == Implementing DAOs Based on JPA: `EntityManagerFactory` and `EntityManager` @@ -292,7 +298,7 @@ shows a plain JPA DAO implementation that uses the `@PersistenceUnit` annotation ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ProductDaoImpl implements ProductDao { @@ -321,7 +327,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ProductDaoImpl : ProductDao { @@ -387,7 +393,7 @@ EntityManager) to be injected instead of the factory. The following example show ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class ProductDaoImpl implements ProductDao { @@ -404,7 +410,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ProductDaoImpl : ProductDao { @@ -462,7 +468,7 @@ a non-invasiveness perspective and can feel more natural to JPA developers. What about providing JPA resources via constructors and other `@Autowired` injection points? `EntityManagerFactory` can easily be injected via constructors and `@Autowired` fields/methods -as long as the target is defined as a bean, e.g. via `LocalContainerEntityManagerFactoryBean`. +as long as the target is defined as a bean, for example, via `LocalContainerEntityManagerFactoryBean`. The injection point matches the original `EntityManagerFactory` definition by type as-is. However, an `@PersistenceContext`-style shared `EntityManager` reference is not available for diff --git a/framework-docs/modules/ROOT/pages/data-access/oxm.adoc b/framework-docs/modules/ROOT/pages/data-access/oxm.adoc index 5bc8fcdfda96..3296f532933e 100644 --- a/framework-docs/modules/ROOT/pages/data-access/oxm.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/oxm.adoc @@ -175,7 +175,7 @@ use a simple JavaBean to represent the settings: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class Settings { @@ -193,7 +193,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class Settings { var isFooEnabled: Boolean = false @@ -210,7 +210,7 @@ constructs a Spring application context and calls these two methods: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import java.io.FileInputStream; import java.io.FileOutputStream; @@ -261,7 +261,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class Application { diff --git a/framework-docs/modules/ROOT/pages/data-access/r2dbc.adoc b/framework-docs/modules/ROOT/pages/data-access/r2dbc.adoc index 672f396ef0f9..c27fd7ec4519 100644 --- a/framework-docs/modules/ROOT/pages/data-access/r2dbc.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/r2dbc.adoc @@ -68,14 +68,14 @@ The simplest way to create a `DatabaseClient` object is through a static factory ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- DatabaseClient client = DatabaseClient.create(connectionFactory); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val client = DatabaseClient.create(connectionFactory) ---- @@ -133,7 +133,7 @@ code that creates a new table: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Mono completion = client.sql("CREATE TABLE person (id VARCHAR(255) PRIMARY KEY, name VARCHAR(255), age INTEGER);") .then(); @@ -141,7 +141,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- client.sql("CREATE TABLE person (id VARCHAR(255) PRIMARY KEY, name VARCHAR(255), age INTEGER);") .await() @@ -170,7 +170,7 @@ The following query gets the `id` and `name` columns from a table: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Mono> first = client.sql("SELECT id, name FROM person") .fetch().first(); @@ -178,7 +178,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val first = client.sql("SELECT id, name FROM person") .fetch().awaitSingle() @@ -191,7 +191,7 @@ The following query uses a bind variable: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Mono> first = client.sql("SELECT id, name FROM person WHERE first_name = :fn") .bind("fn", "Joe") @@ -200,7 +200,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val first = client.sql("SELECT id, name FROM person WHERE first_name = :fn") .bind("fn", "Joe") @@ -237,7 +237,7 @@ The following example extracts the `name` column and emits its value: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Flux names = client.sql("SELECT name FROM person") .map(row -> row.get("name", String.class)) @@ -246,7 +246,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val names = client.sql("SELECT name FROM person") .map{ row: Row -> row.get("name", String.class) } @@ -298,7 +298,7 @@ of updated rows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Mono affectedRows = client.sql("UPDATE person SET first_name = :fn") .bind("fn", "Joe") @@ -307,7 +307,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val affectedRows = client.sql("UPDATE person SET first_name = :fn") .bind("fn", "Joe") @@ -364,6 +364,28 @@ Or you may pass in a parameter object with bean properties or record components: .bindProperties(new Person("joe", "Joe", 34); ---- +Alternatively, you can use positional parameters for binding values to statements. +Indices are zero based. + +[source,java] +---- + db.sql("INSERT INTO person (id, name, age) VALUES(:id, :name, :age)") + .bind(0, "joe") + .bind(1, "Joe") + .bind(2, 34); +---- + +In case your application is binding to many parameters, the same can be achieved with a single call: + +[source,java] +---- + List values = List.of("joe", "Joe", 34); + db.sql("INSERT INTO person (id, name, age) VALUES(:id, :name, :age)") + .bindValues(values); +---- + + + .R2DBC Native Bind Markers **** R2DBC uses database-native bind markers that depend on the actual database vendor. @@ -399,7 +421,7 @@ The preceding query can be parameterized and run as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- List tuples = new ArrayList<>(); tuples.add(new Object[] {"John", 35}); @@ -411,7 +433,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val tuples: MutableList> = ArrayList() tuples.add(arrayOf("John", 35)) @@ -430,7 +452,7 @@ The following example shows a simpler variant using `IN` predicates: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- client.sql("SELECT id, name, state FROM table WHERE age IN (:ages)") .bind("ages", Arrays.asList(35, 50)); @@ -438,7 +460,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- client.sql("SELECT id, name, state FROM table WHERE age IN (:ages)") .bind("ages", arrayOf(35, 50)) @@ -447,10 +469,10 @@ Kotlin:: NOTE: R2DBC itself does not support Collection-like values. Nevertheless, expanding a given `List` in the example above works for named parameters -in Spring's R2DBC support, e.g. for use in `IN` clauses as shown above. -However, inserting or updating array-typed columns (e.g. in Postgres) +in Spring's R2DBC support, for example, for use in `IN` clauses as shown above. +However, inserting or updating array-typed columns (for example, in Postgres) requires an array type that is supported by the underlying R2DBC driver: -typically a Java array, e.g. `String[]` to update a `text[]` column. +typically a Java array, for example, `String[]` to update a `text[]` column. Do not pass `Collection` or the like as an array parameter. [[r2dbc-DatabaseClient-filter]] @@ -465,7 +487,7 @@ modify statements in their execution, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- client.sql("INSERT INTO table (name, state) VALUES(:name, :state)") .filter((s, next) -> next.execute(s.returnGeneratedValues("id"))) @@ -475,7 +497,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- client.sql("INSERT INTO table (name, state) VALUES(:name, :state)") .filter { s: Statement, next: ExecuteFunction -> next.execute(s.returnGeneratedValues("id")) } @@ -491,7 +513,7 @@ a `Function`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- client.sql("INSERT INTO table (name, state) VALUES(:name, :state)") .filter(statement -> s.returnGeneratedValues("id")); @@ -502,7 +524,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- client.sql("INSERT INTO table (name, state) VALUES(:name, :state)") .filter { statement -> s.returnGeneratedValues("id") } @@ -534,7 +556,7 @@ the setter for the `ConnectionFactory`. This leads to DAOs that resemble the fol ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class R2dbcCorporateEventDao implements CorporateEventDao { @@ -550,7 +572,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class R2dbcCorporateEventDao(connectionFactory: ConnectionFactory) : CorporateEventDao { @@ -572,7 +594,7 @@ method with `@Autowired`. The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component // <1> public class R2dbcCorporateEventDao implements CorporateEventDao { @@ -593,7 +615,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component // <1> class R2dbcCorporateEventDao(connectionFactory: ConnectionFactory) : CorporateEventDao { // <2> @@ -629,7 +651,7 @@ requests the generated key for the desired column. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Mono generatedId = client.sql("INSERT INTO table (name, state) VALUES(:name, :state)") .filter(statement -> s.returnGeneratedValues("id")) @@ -641,7 +663,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val generatedId = client.sql("INSERT INTO table (name, state) VALUES(:name, :state)") .filter { statement -> s.returnGeneratedValues("id") } @@ -694,14 +716,14 @@ The following example shows how to configure a `ConnectionFactory`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ConnectionFactory factory = ConnectionFactories.get("r2dbc:h2:mem:///test?options=DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val factory = ConnectionFactories.get("r2dbc:h2:mem:///test?options=DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"); ---- diff --git a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/annotations.adoc b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/annotations.adoc index a866ccb56436..f6e75c09c35e 100644 --- a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/annotations.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/annotations.adoc @@ -19,7 +19,7 @@ Consider the following class definition: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // the service class that we want to make transactional @Transactional @@ -49,7 +49,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // the service class that we want to make transactional @Transactional @@ -138,7 +138,7 @@ programming arrangements as the following listing shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // the reactive service class that we want to make transactional @Transactional @@ -168,7 +168,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // the reactive service class that we want to make transactional @Transactional @@ -250,7 +250,7 @@ the proxy are intercepted. This means that self-invocation (in effect, a method the target object calling another method of the target object) does not lead to an actual transaction at runtime even if the invoked method is marked with `@Transactional`. Also, the proxy must be fully initialized to provide the expected behavior, so you should not -rely on this feature in your initialization code -- e.g. in a `@PostConstruct` method. +rely on this feature in your initialization code -- for example, in a `@PostConstruct` method. Consider using AspectJ mode (see the `mode` attribute in the following table) if you expect self-invocations to be wrapped with transactions as well. In this case, there is @@ -327,7 +327,7 @@ precedence over the transactional settings defined at the class level. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Transactional(readOnly = true) public class DefaultFooService implements FooService { @@ -346,7 +346,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Transactional(readOnly = true) class DefaultFooService : FooService { @@ -442,12 +442,32 @@ xref:data-access/transaction/declarative/rolling-back.adoc#transaction-declarati for further details on rollback rule semantics, patterns, and warnings regarding possible unintentional matches for pattern-based rollback rules. +[NOTE] +==== +As of 6.2, you can globally change the default rollback behavior – for example, through +`@EnableTransactionManagement(rollbackOn=ALL_EXCEPTIONS)`, leading to a rollback +for all exceptions raised within a transaction, including any checked exception. +For further customizations, `AnnotationTransactionAttributeSource` provides an +`addDefaultRollbackRule(RollbackRuleAttribute)` method for custom default rules. + +Note that transaction-specific rollback rules override the default behavior but +retain the chosen default for unspecified exceptions. This is the case for +Spring's `@Transactional` as well as JTA's `jakarta.transaction.Transactional` +annotation. + +Unless you rely on EJB-style business exceptions with commit behavior, it is +advisable to switch to `ALL_EXCEPTIONS` for consistent rollback semantics even +in case of a (potentially accidental) checked exception. Also, it is advisable +to make that switch for Kotlin-based applications where there is no enforcement +of checked exceptions at all. +==== + Currently, you cannot have explicit control over the name of a transaction, where 'name' means the transaction name that appears in a transaction monitor and in logging output. For declarative transactions, the transaction name is always the fully-qualified class -name + `.` + the method name of the transactionally advised class. For example, if the +name of the transactionally advised class + `.` + the method name. For example, if the `handlePayment(..)` method of the `BusinessService` class started a transaction, the -name of the transaction would be: `com.example.BusinessService.handlePayment`. +name of the transaction would be `com.example.BusinessService.handlePayment`. [[tx-multiple-tx-mgrs-with-attransactional]] == Multiple Transaction Managers with `@Transactional` @@ -465,7 +485,7 @@ in the application context: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class TransactionalService { @@ -482,7 +502,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- class TransactionalService { @@ -531,19 +551,39 @@ transaction managers, differentiated by the `order`, `account`, and `reactive-ac qualifiers. The default `` target bean name, `transactionManager`, is still used if no specifically qualified `TransactionManager` bean is found. +[TIP] +==== +If all transactional methods on the same class share the same qualifier, consider +declaring a type-level `org.springframework.beans.factory.annotation.Qualifier` +annotation instead. If its value matches the qualifier value (or bean name) of a +specific transaction manager, that transaction manager is going to be used for +transaction definitions without a specific qualifier on `@Transactional` itself. + +Such a type-level qualifier can be declared on the concrete class, applying to +transaction definitions from a base class as well. This effectively overrides +the default transaction manager choice for any unqualified base class methods. + +Last but not least, such a type-level bean qualifier can serve multiple purposes, +for example, with a value of "order" it can be used for autowiring purposes (identifying +the order repository) as well as transaction manager selection, as long as the +target beans for autowiring as well as the associated transaction manager +definitions declare the same qualifier value. Such a qualifier value only needs +to be unique within a set of type-matching beans, not having to serve as an ID. +==== + [[tx-custom-attributes]] == Custom Composed Annotations -If you find you repeatedly use the same attributes with `@Transactional` on many different -methods, xref:core/beans/classpath-scanning.adoc#beans-meta-annotations[Spring's meta-annotation support] lets you -define custom composed annotations for your specific use cases. For example, consider the +If you find you repeatedly use the same attributes with `@Transactional` on many different methods, +xref:core/beans/classpath-scanning.adoc#beans-meta-annotations[Spring's meta-annotation support] +lets you define custom composed annotations for your specific use cases. For example, consider the following annotation definitions: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @@ -560,7 +600,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Target(AnnotationTarget.FUNCTION, AnnotationTarget.TYPE) @Retention(AnnotationRetention.RUNTIME) @@ -580,7 +620,7 @@ The preceding annotations let us write the example from the previous section as ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class TransactionalService { @@ -598,7 +638,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- class TransactionalService { diff --git a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/applying-more-than-just-tx-advice.adoc b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/applying-more-than-just-tx-advice.adoc index bcd41b9ab7cf..1a14bb5e499e 100644 --- a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/applying-more-than-just-tx-advice.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/applying-more-than-just-tx-advice.adoc @@ -22,7 +22,7 @@ The following code shows the simple profiling aspect discussed earlier: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package x.y; @@ -61,7 +61,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim",chomp="-packages"] ---- package x.y diff --git a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/aspectj.adoc b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/aspectj.adoc index af4de5b40077..b112eef49519 100644 --- a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/aspectj.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/aspectj.adoc @@ -24,7 +24,7 @@ The following example shows how to create a transaction manager and configure th ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // construct an appropriate transaction manager DataSourceTransactionManager txManager = new DataSourceTransactionManager(getDataSource()); @@ -35,7 +35,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // construct an appropriate transaction manager val txManager = DataSourceTransactionManager(getDataSource()) diff --git a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/first-example.adoc b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/first-example.adoc index c84bd17412ff..692de56d9e1f 100644 --- a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/first-example.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/first-example.adoc @@ -14,7 +14,7 @@ interface: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- // the service interface that we want to make transactional @@ -35,7 +35,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- // the service interface that we want to make transactional @@ -60,7 +60,7 @@ The following example shows an implementation of the preceding interface: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package x.y.service; @@ -90,7 +90,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package x.y.service @@ -231,7 +231,7 @@ that test drives the configuration shown earlier: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public final class Boot { @@ -245,7 +245,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.beans.factory.getBean @@ -303,7 +303,7 @@ this time the code uses reactive types: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- // the reactive service interface that we want to make transactional @@ -324,7 +324,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- // the reactive service interface that we want to make transactional @@ -349,7 +349,7 @@ The following example shows an implementation of the preceding interface: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary",chomp="-packages"] +[source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package x.y.service; @@ -379,7 +379,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary",chomp="-packages"] +[source,kotlin,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package x.y.service diff --git a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/rolling-back.adoc b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/rolling-back.adoc index dca97fd50248..c261821fa235 100644 --- a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/rolling-back.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/rolling-back.adoc @@ -19,8 +19,8 @@ marks a transaction for rollback only in the case of runtime, unchecked exceptio That is, when the thrown exception is an instance or subclass of `RuntimeException`. (`Error` instances also, by default, result in a rollback). -As of Spring Framework 5.2, the default configuration also provides support for -Vavr's `Try` method to trigger transaction rollbacks when it returns a 'Failure'. +The default configuration also provides support for Vavr's `Try` method to trigger +transaction rollbacks when it returns a 'Failure'. This allows you to handle functional-style errors using Try and have the transaction automatically rolled back in case of a failure. For more information on Vavr's Try, refer to the {vavr-docs}/#_try[official Vavr documentation]. @@ -30,7 +30,7 @@ Here's an example of how to use Vavr's Try with a transactional method: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Transactional public Try myTransactionalMethod() { @@ -54,7 +54,7 @@ preferring exposure in the returned handle rather than rethrowing an exception: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Transactional @Async public CompletableFuture myTransactionalMethod() { @@ -172,7 +172,7 @@ rollback: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public void resolvePosition() { try { @@ -186,7 +186,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun resolvePosition() { try { diff --git a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/tx-decl-explained.adoc b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/tx-decl-explained.adoc index e27f496de9fe..afece4181a30 100644 --- a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/tx-decl-explained.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/tx-decl-explained.adoc @@ -41,7 +41,7 @@ operations need to execute within the same Reactor context in the same reactive When configured with a `ReactiveTransactionManager`, all transaction-demarcated methods are expected to return a reactive pipeline. Void methods or regular return types need -to be associated with a regular `PlatformTransactionManager`, e.g. through the +to be associated with a regular `PlatformTransactionManager`, for example, through the `transactionManager` attribute of the corresponding `@Transactional` declarations. ==== diff --git a/framework-docs/modules/ROOT/pages/data-access/transaction/event.adoc b/framework-docs/modules/ROOT/pages/data-access/transaction/event.adoc index 62749a4d5842..8ee00835b25f 100644 --- a/framework-docs/modules/ROOT/pages/data-access/transaction/event.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/transaction/event.adoc @@ -19,7 +19,7 @@ example sets up such an event listener: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Component public class MyComponent { @@ -33,7 +33,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Component class MyComponent { diff --git a/framework-docs/modules/ROOT/pages/data-access/transaction/programmatic.adoc b/framework-docs/modules/ROOT/pages/data-access/transaction/programmatic.adoc index 6c4bbb7021fa..1c14f7893a54 100644 --- a/framework-docs/modules/ROOT/pages/data-access/transaction/programmatic.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/transaction/programmatic.adoc @@ -37,7 +37,7 @@ a transaction. You can then pass an instance of your custom `TransactionCallback ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SimpleService implements Service { @@ -63,7 +63,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // use constructor-injection to supply the PlatformTransactionManager class SimpleService(transactionManager: PlatformTransactionManager) : Service { @@ -87,7 +87,7 @@ with an anonymous class, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- transactionTemplate.execute(new TransactionCallbackWithoutResult() { protected void doInTransactionWithoutResult(TransactionStatus status) { @@ -99,7 +99,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- transactionTemplate.execute(object : TransactionCallbackWithoutResult() { override fun doInTransactionWithoutResult(status: TransactionStatus) { @@ -118,7 +118,7 @@ Code within the callback can roll the transaction back by calling the ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- transactionTemplate.execute(new TransactionCallbackWithoutResult() { @@ -135,7 +135,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- transactionTemplate.execute(object : TransactionCallbackWithoutResult() { @@ -165,7 +165,7 @@ a specific `TransactionTemplate:` ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SimpleService implements Service { @@ -184,7 +184,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class SimpleService(transactionManager: PlatformTransactionManager) : Service { @@ -240,7 +240,7 @@ the `TransactionalOperator` resembles the next example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SimpleService implements Service { @@ -265,7 +265,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // use constructor-injection to supply the ReactiveTransactionManager class SimpleService(transactionManager: ReactiveTransactionManager) : Service { @@ -293,7 +293,7 @@ method on the supplied `ReactiveTransaction` object, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- transactionalOperator.execute(new TransactionCallback<>() { @@ -307,7 +307,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- transactionalOperator.execute(object : TransactionCallback() { @@ -346,7 +346,7 @@ following example shows customization of the transactional settings for a specif ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SimpleService implements Service { @@ -367,7 +367,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class SimpleService(transactionManager: ReactiveTransactionManager) : Service { @@ -402,7 +402,7 @@ following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- DefaultTransactionDefinition def = new DefaultTransactionDefinition(); // explicitly setting the transaction name is something that can be done only programmatically @@ -421,7 +421,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val def = DefaultTransactionDefinition() // explicitly setting the transaction name is something that can be done only programmatically @@ -455,7 +455,7 @@ following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- DefaultTransactionDefinition def = new DefaultTransactionDefinition(); // explicitly setting the transaction name is something that can be done only programmatically @@ -475,7 +475,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val def = DefaultTransactionDefinition() // explicitly setting the transaction name is something that can be done only programmatically diff --git a/framework-docs/modules/ROOT/pages/data-access/transaction/strategies.adoc b/framework-docs/modules/ROOT/pages/data-access/transaction/strategies.adoc index d64cef5a2bf3..d052af9dd6ea 100644 --- a/framework-docs/modules/ROOT/pages/data-access/transaction/strategies.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/transaction/strategies.adoc @@ -45,9 +45,9 @@ exists in the current call stack. The implication in this latter case is that, a Jakarta EE transaction contexts, a `TransactionStatus` is associated with a thread of execution. -As of Spring Framework 5.2, Spring also provides a transaction management abstraction for -reactive applications that make use of reactive types or Kotlin Coroutines. The following -listing shows the transaction strategy defined by +Spring also provides a transaction management abstraction for reactive applications that +make use of reactive types or Kotlin Coroutines. The following listing shows the +transaction strategy defined by `org.springframework.transaction.ReactiveTransactionManager`: [source,java,indent=0,subs="verbatim,quotes"] diff --git a/framework-docs/modules/ROOT/pages/index.adoc b/framework-docs/modules/ROOT/pages/index.adoc index 6c0a08843f79..d3157a5c6a9a 100644 --- a/framework-docs/modules/ROOT/pages/index.adoc +++ b/framework-docs/modules/ROOT/pages/index.adoc @@ -7,7 +7,7 @@ xref:overview.adoc[Overview] :: History, Design Philosophy, Feedback, Getting Started. xref:core.adoc[Core] :: IoC Container, Events, Resources, i18n, Validation, Data Binding, Type Conversion, SpEL, AOP, AOT. -<> :: Mock Objects, TestContext Framework, +xref:testing.adoc[Testing] :: Mock Objects, TestContext Framework, Spring MVC Test, WebTestClient. xref:data-access.adoc[Data Access] :: Transactions, DAO Support, JDBC, R2DBC, O/R Mapping, XML Marshalling. diff --git a/framework-docs/modules/ROOT/pages/integration/cache/annotations.adoc b/framework-docs/modules/ROOT/pages/integration/cache/annotations.adoc index b25668ef5c48..61ecce6957ed 100644 --- a/framework-docs/modules/ROOT/pages/integration/cache/annotations.adoc +++ b/framework-docs/modules/ROOT/pages/integration/cache/annotations.adoc @@ -501,12 +501,12 @@ Placing this annotation on the class does not turn on any caching operation. An operation-level customization always overrides a customization set on `@CacheConfig`. Therefore, this gives three levels of customizations for each cache operation: -* Globally configured, e.g. through `CachingConfigurer`: see next section. +* Globally configured, for example, through `CachingConfigurer`: see next section. * At the class level, using `@CacheConfig`. * At the operation level. NOTE: Provider-specific settings are typically available on the `CacheManager` bean, -e.g. on `CaffeineCacheManager`. These are effectively also global. +for example, on `CaffeineCacheManager`. These are effectively also global. [[cache-annotation-enable]] @@ -519,41 +519,9 @@ disable it by removing only one configuration line rather than all the annotatio your code). To enable caching annotations add the annotation `@EnableCaching` to one of your -`@Configuration` classes: +`@Configuration` classes or use the `cache:annotation-driven` element with XML: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableCaching - class AppConfig { - - @Bean - CacheManager cacheManager() { - CaffeineCacheManager cacheManager = new CaffeineCacheManager(); - cacheManager.setCacheSpecification(...); - return cacheManager; - } - } ----- - -Alternatively, for XML configuration you can use the `cache:annotation-driven` element: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - ----- +include-code::./CacheConfiguration[tag=snippet,indent=0] Both the `cache:annotation-driven` element and the `@EnableCaching` annotation let you specify various options that influence the way the caching behavior is added to the diff --git a/framework-docs/modules/ROOT/pages/integration/cache/store-configuration.adoc b/framework-docs/modules/ROOT/pages/integration/cache/store-configuration.adoc index c8009ce25154..ed350b2385e5 100644 --- a/framework-docs/modules/ROOT/pages/integration/cache/store-configuration.adoc +++ b/framework-docs/modules/ROOT/pages/integration/cache/store-configuration.adoc @@ -13,18 +13,7 @@ The JDK-based `Cache` implementation resides under `org.springframework.cache.concurrent` package. It lets you use `ConcurrentHashMap` as a backing `Cache` store. The following example shows how to configure two caches: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - ----- +include-code::./CacheConfiguration[tag=snippet,indent=0] The preceding snippet uses the `SimpleCacheManager` to create a `CacheManager` for the two nested `ConcurrentMapCache` instances named `default` and `books`. Note that the @@ -52,26 +41,12 @@ of Caffeine. The following example configures a `CacheManager` that creates the cache on demand: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - ----- +include-code::./CacheConfiguration[tag=snippet,indent=0] You can also provide the caches to use explicitly. In that case, only those are made available by the manager. The following example shows how to do so: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - default - books - - - ----- +include-code::./CustomCacheConfiguration[tag=snippet,indent=0] The Caffeine `CacheManager` also supports custom `Caffeine` and `CacheLoader`. See the https://github.com/ben-manes/caffeine/wiki[Caffeine documentation] @@ -97,15 +72,7 @@ implementation is located in the `org.springframework.cache.jcache` package. Again, to use it, you need to declare the appropriate `CacheManager`. The following example shows how to do so: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - ----- +include-code::./CacheConfiguration[tag=snippet,indent=0] [[cache-store-configuration-noop]] @@ -119,18 +86,7 @@ cache declarations (which can prove tedious), you can wire in a simple dummy cac performs no caching -- that is, it forces the cached methods to be invoked every time. The following example shows how to do so: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - ----- +include-code::./CacheConfiguration[tag=snippet,indent=0] The `CompositeCacheManager` in the preceding chains multiple `CacheManager` instances and, through the `fallbackToNoOpCache` flag, adds a no-op cache for all the definitions not diff --git a/framework-docs/modules/ROOT/pages/integration/email.adoc b/framework-docs/modules/ROOT/pages/integration/email.adoc index 19568c212c26..46493a7de9a0 100644 --- a/framework-docs/modules/ROOT/pages/integration/email.adoc +++ b/framework-docs/modules/ROOT/pages/integration/email.adoc @@ -41,14 +41,7 @@ JavaMail features, such as MIME message support to the `MailSender` interface Assume that we have a business interface called `OrderManager`, as the following example shows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - public interface OrderManager { - - void placeOrder(Order order); - - } ----- +include-code::./OrderManager[tag=snippet,indent=0] Further assume that we have a requirement stating that an email message with an order number needs to be generated and sent to a customer who placed the relevant order. @@ -60,70 +53,11 @@ order number needs to be generated and sent to a customer who placed the relevan The following example shows how to use `MailSender` and `SimpleMailMessage` to send an email when someone places an order: -[source,java,indent=0,subs="verbatim,quotes"] ----- - import org.springframework.mail.MailException; - import org.springframework.mail.MailSender; - import org.springframework.mail.SimpleMailMessage; - - public class SimpleOrderManager implements OrderManager { - - private MailSender mailSender; - private SimpleMailMessage templateMessage; - - public void setMailSender(MailSender mailSender) { - this.mailSender = mailSender; - } - - public void setTemplateMessage(SimpleMailMessage templateMessage) { - this.templateMessage = templateMessage; - } - - public void placeOrder(Order order) { - - // Do the business calculations... - - // Call the collaborators to persist the order... - - // Create a thread-safe "copy" of the template message and customize it - SimpleMailMessage msg = new SimpleMailMessage(this.templateMessage); - msg.setTo(order.getCustomer().getEmailAddress()); - msg.setText( - "Dear " + order.getCustomer().getFirstName() - + order.getCustomer().getLastName() - + ", thank you for placing order. Your order number is " - + order.getOrderNumber()); - try { - this.mailSender.send(msg); - } - catch (MailException ex) { - // simply log it and go on... - System.err.println(ex.getMessage()); - } - } - - } ----- +include-code::./SimpleOrderManager[tag=snippet,indent=0] The following example shows the bean definitions for the preceding code: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - - - - - - ----- +include-code::./MailConfiguration[tag=snippet,indent=0] [[mail-usage-mime]] diff --git a/framework-docs/modules/ROOT/pages/integration/jms.adoc b/framework-docs/modules/ROOT/pages/integration/jms.adoc index ee207db0e088..d3ca79413b48 100644 --- a/framework-docs/modules/ROOT/pages/integration/jms.adoc +++ b/framework-docs/modules/ROOT/pages/integration/jms.adoc @@ -6,7 +6,7 @@ the same way as Spring's integration does for the JDBC API. JMS can be roughly divided into two areas of functionality, namely the production and consumption of messages. The `JmsTemplate` class is used for message production and -synchronous message reception. For asynchronous reception similar to Jakarta EE's +synchronous message receipt. For asynchronous receipt similar to Jakarta EE's message-driven bean style, Spring provides a number of message-listener containers that you can use to create Message-Driven POJOs (MDPs). Spring also provides a declarative way to create message listeners. diff --git a/framework-docs/modules/ROOT/pages/integration/jms/annotated.adoc b/framework-docs/modules/ROOT/pages/integration/jms/annotated.adoc index e2cbe5fe91d9..586538b49199 100644 --- a/framework-docs/modules/ROOT/pages/integration/jms/annotated.adoc +++ b/framework-docs/modules/ROOT/pages/integration/jms/annotated.adoc @@ -37,23 +37,7 @@ declarations to it. To enable support for `@JmsListener` annotations, you can add `@EnableJms` to one of your `@Configuration` classes, as the following example shows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableJms - public class AppConfig { - - @Bean - public DefaultJmsListenerContainerFactory jmsListenerContainerFactory() { - DefaultJmsListenerContainerFactory factory = new DefaultJmsListenerContainerFactory(); - factory.setConnectionFactory(connectionFactory()); - factory.setDestinationResolver(destinationResolver()); - factory.setSessionTransacted(true); - factory.setConcurrency("3-10"); - return factory; - } - } ----- +include-code::./JmsConfiguration[tag=snippet,indent=0] By default, the infrastructure looks for a bean named `jmsListenerContainerFactory` as the source for the factory to use to create message listener containers. In this @@ -67,22 +51,6 @@ container factory. See the javadoc of classes that implement {spring-framework-api}/jms/annotation/JmsListenerConfigurer.html[`JmsListenerConfigurer`] for details and examples. -If you prefer xref:integration/jms/namespace.adoc[XML configuration], you can use the `` -element, as the following example shows: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - ----- - [[jms-annotated-programmatic-registration]] == Programmatic Endpoint Registration diff --git a/framework-docs/modules/ROOT/pages/integration/jms/jca-message-endpoint-manager.adoc b/framework-docs/modules/ROOT/pages/integration/jms/jca-message-endpoint-manager.adoc index 70942ae58dd7..8838f449d702 100644 --- a/framework-docs/modules/ROOT/pages/integration/jms/jca-message-endpoint-manager.adoc +++ b/framework-docs/modules/ROOT/pages/integration/jms/jca-message-endpoint-manager.adoc @@ -7,62 +7,13 @@ automatically determine the `ActivationSpec` class name from the provider's `ResourceAdapter` class name. Therefore, it is typically possible to provide Spring's generic `JmsActivationSpecConfig`, as the following example shows: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - ----- +include-code::./JmsConfiguration[tag=snippet,indent=0] Alternatively, you can set up a `JmsMessageEndpointManager` with a given `ActivationSpec` object. The `ActivationSpec` object may also come from a JNDI lookup (using ``). The following example shows how to do so: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - - ----- - -Using Spring's `ResourceAdapterFactoryBean`, you can configure the target `ResourceAdapter` -locally, as the following example shows: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - - ----- - -The specified `WorkManager` can also point to an environment-specific thread pool -- -typically through a `SimpleTaskWorkManager` instance's `asyncTaskExecutor` property. -Consider defining a shared thread pool for all your `ResourceAdapter` instances -if you happen to use multiple adapters. - -In some environments, you can instead obtain the entire `ResourceAdapter` object from JNDI -(by using ``). The Spring-based message listeners can then interact with -the server-hosted `ResourceAdapter`, which also use the server's built-in `WorkManager`. +include-code::./AlternativeJmsConfiguration[tag=snippet,indent=0] See the javadoc for {spring-framework-api}/jms/listener/endpoint/JmsMessageEndpointManager.html[`JmsMessageEndpointManager`], {spring-framework-api}/jms/listener/endpoint/JmsActivationSpecConfig.html[`JmsActivationSpecConfig`], diff --git a/framework-docs/modules/ROOT/pages/integration/jms/receiving.adoc b/framework-docs/modules/ROOT/pages/integration/jms/receiving.adoc index 81388f3ae0f5..ddd9c43d2e86 100644 --- a/framework-docs/modules/ROOT/pages/integration/jms/receiving.adoc +++ b/framework-docs/modules/ROOT/pages/integration/jms/receiving.adoc @@ -5,7 +5,7 @@ This describes how to receive messages with JMS in Spring. [[jms-receiving-sync]] -== Synchronous Reception +== Synchronous Receipt While JMS is typically associated with asynchronous processing, you can consume messages synchronously. The overloaded `receive(..)` methods provide this @@ -16,11 +16,11 @@ the receiver should wait before giving up waiting for a message. [[jms-receiving-async]] -== Asynchronous reception: Message-Driven POJOs +== Asynchronous Receipt: Message-Driven POJOs NOTE: Spring also supports annotated-listener endpoints through the use of the `@JmsListener` -annotation and provides an open infrastructure to register endpoints programmatically. -This is, by far, the most convenient way to setup an asynchronous receiver. +annotation and provides open infrastructure to register endpoints programmatically. +This is, by far, the most convenient way to set up an asynchronous receiver. See xref:integration/jms/annotated.adoc#jms-annotated-support[Enable Listener Endpoint Annotations] for more details. In a fashion similar to a Message-Driven Bean (MDB) in the EJB world, the Message-Driven @@ -31,30 +31,7 @@ on multiple threads, it is important to ensure that your implementation is threa The following example shows a simple implementation of an MDP: -[source,java,indent=0,subs="verbatim,quotes"] ----- - import jakarta.jms.JMSException; - import jakarta.jms.Message; - import jakarta.jms.MessageListener; - import jakarta.jms.TextMessage; - - public class ExampleListener implements MessageListener { - - public void onMessage(Message message) { - if (message instanceof TextMessage textMessage) { - try { - System.out.println(textMessage.getText()); - } - catch (JMSException ex) { - throw new RuntimeException(ex); - } - } - else { - throw new IllegalArgumentException("Message must be of type TextMessage"); - } - } - } ----- +include-code::./ExampleListener[tag=snippet,indent=0] Once you have implemented your `MessageListener`, it is time to create a message listener container. @@ -62,18 +39,7 @@ container. The following example shows how to define and configure one of the message listener containers that ships with Spring (in this case, `DefaultMessageListenerContainer`): -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - ----- +include-code::./JmsConfiguration[tag=snippet,indent=0] See the Spring javadoc of the various message listener containers (all of which implement {spring-framework-api}/jms/listener/MessageListenerContainer.html[MessageListenerContainer]) @@ -123,19 +89,7 @@ messaging support. In a nutshell, it lets you expose almost any class as an MDP Consider the following interface definition: -[source,java,indent=0,subs="verbatim,quotes"] ----- - public interface MessageDelegate { - - void handleMessage(String message); - - void handleMessage(Map message); - - void handleMessage(byte[] message); - - void handleMessage(Serializable message); - } ----- +include-code::./MessageDelegate[tag=snippet,indent=0] Notice that, although the interface extends neither the `MessageListener` nor the `SessionAwareMessageListener` interface, you can still use it as an MDP by using the @@ -145,33 +99,13 @@ receive and handle. Now consider the following implementation of the `MessageDelegate` interface: -[source,java,indent=0,subs="verbatim,quotes"] ----- - public class DefaultMessageDelegate implements MessageDelegate { - // implementation elided for clarity... - } ----- +include-code::./DefaultMessageDelegate[tag=snippet,indent=0] In particular, note how the preceding implementation of the `MessageDelegate` interface (the `DefaultMessageDelegate` class) has no JMS dependencies at all. It truly is a POJO that we can make into an MDP through the following configuration: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - - - - - ----- +include-code::./JmsConfiguration[tag=snippet,indent=0] The next example shows another MDP that can handle only receiving JMS `TextMessage` messages. Notice how the message handling method is actually called @@ -181,38 +115,15 @@ also how the `receive(..)` method is strongly typed to receive and respond only `TextMessage` messages. The following listing shows the definition of the `TextMessageDelegate` interface: -[source,java,indent=0,subs="verbatim,quotes"] ----- - public interface TextMessageDelegate { - - void receive(TextMessage message); - } ----- +include-code::./TextMessageDelegate[tag=snippet,indent=0] The following listing shows a class that implements the `TextMessageDelegate` interface: -[source,java,indent=0,subs="verbatim,quotes"] ----- - public class DefaultTextMessageDelegate implements TextMessageDelegate { - // implementation elided for clarity... - } ----- +include-code::./DefaultTextMessageDelegate[tag=snippet,indent=0] The configuration of the attendant `MessageListenerAdapter` would then be as follows: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - - ----- +include-code::./MessageListenerConfiguration[tag=snippet,indent=0] Note that, if the `messageListener` receives a JMS `Message` of a type other than `TextMessage`, an `IllegalStateException` is thrown (and subsequently @@ -220,21 +131,9 @@ swallowed). Another of the capabilities of the `MessageListenerAdapter` class is ability to automatically send back a response `Message` if a handler method returns a non-void value. Consider the following interface and class: -[source,java,indent=0,subs="verbatim,quotes"] ----- - public interface ResponsiveTextMessageDelegate { +include-code::./ResponsiveTextMessageDelegate[tag=snippet,indent=0] - // notice the return type... - String receive(TextMessage message); - } ----- - -[source,java,indent=0,subs="verbatim,quotes"] ----- - public class DefaultResponsiveTextMessageDelegate implements ResponsiveTextMessageDelegate { - // implementation elided for clarity... - } ----- +include-code::./DefaultResponsiveTextMessageDelegate[tag=snippet,indent=0] If you use the `DefaultResponsiveTextMessageDelegate` in conjunction with a `MessageListenerAdapter`, any non-null value that is returned from the execution of @@ -255,7 +154,7 @@ listener container. You can activate local resource transactions through the `sessionTransacted` flag on the listener container definition. Each message listener invocation then operates -within an active JMS transaction, with message reception rolled back in case of listener +within an active JMS transaction, with message receipt rolled back in case of listener execution failure. Sending a response message (through `SessionAwareMessageListener`) is part of the same local transaction, but any other resource operations (such as database access) operate independently. This usually requires duplicate message @@ -264,15 +163,7 @@ has committed but message processing failed to commit. Consider the following bean definition: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - ----- +include-code::./JmsConfiguration[tag=snippet,indent=0] To participate in an externally managed transaction, you need to configure a transaction manager and use a listener container that supports externally managed @@ -282,30 +173,15 @@ To configure a message listener container for XA transaction participation, you to configure a `JtaTransactionManager` (which, by default, delegates to the Jakarta EE server's transaction subsystem). Note that the underlying JMS `ConnectionFactory` needs to be XA-capable and properly registered with your JTA transaction coordinator. (Check your -Jakarta EE server's configuration of JNDI resources.) This lets message reception as well +Jakarta EE server's configuration of JNDI resources.) This lets message receipt as well as (for example) database access be part of the same transaction (with unified commit semantics, at the expense of XA transaction log overhead). The following bean definition creates a transaction manager: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - ----- +include-code::./ExternalTxJmsConfiguration[tag=transactionManagerSnippet,indent=0] Then we need to add it to our earlier container configuration. The container takes care of the rest. The following example shows how to do so: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - <1> - ----- -<1> Our transaction manager. - - - +include-code::./ExternalTxJmsConfiguration[tag=jmsContainerSnippet,indent=0] diff --git a/framework-docs/modules/ROOT/pages/integration/jms/using.adoc b/framework-docs/modules/ROOT/pages/integration/jms/using.adoc index 027098cbc205..01babeefac6e 100644 --- a/framework-docs/modules/ROOT/pages/integration/jms/using.adoc +++ b/framework-docs/modules/ROOT/pages/integration/jms/using.adoc @@ -167,13 +167,15 @@ operations that do not refer to a specific destination. One of the most common uses of JMS messages in the EJB world is to drive message-driven beans (MDBs). Spring offers a solution to create message-driven POJOs (MDPs) in a way -that does not tie a user to an EJB container. (See xref:integration/jms/receiving.adoc#jms-receiving-async[Asynchronous reception: Message-Driven POJOs] for detailed -coverage of Spring's MDP support.) Since Spring Framework 4.1, endpoint methods can be -annotated with `@JmsListener` -- see xref:integration/jms/annotated.adoc[Annotation-driven Listener Endpoints] for more details. +that does not tie a user to an EJB container. (See +xref:integration/jms/receiving.adoc#jms-receiving-async[Asynchronous Receipt: Message-Driven POJOs] +for detailed coverage of Spring's MDP support.) Endpoint methods can be annotated with +`@JmsListener` -- see xref:integration/jms/annotated.adoc[Annotation-driven Listener Endpoints] +for more details. A message listener container is used to receive messages from a JMS message queue and drive the `MessageListener` that is injected into it. The listener container is -responsible for all threading of message reception and dispatches into the listener for +responsible for all threading of message receipt and dispatches into the listener for processing. A message listener container is the intermediary between an MDP and a messaging provider and takes care of registering to receive messages, participating in transactions, resource acquisition and release, exception conversion, and so on. This @@ -227,7 +229,7 @@ the JMS provider, advanced functionality (such as participation in externally ma transactions), and compatibility with Jakarta EE environments. You can customize the cache level of the container. Note that, when no caching is enabled, -a new connection and a new session is created for each message reception. Combining this +a new connection and a new session is created for each message receipt. Combining this with a non-durable subscription with high loads may lead to message loss. Make sure to use a proper cache level in such a case. @@ -246,7 +248,7 @@ in the form of a business entity existence check or a protocol table check. Any such arrangements are significantly more efficient than the alternative: wrapping your entire processing with an XA transaction (through configuring your `DefaultMessageListenerContainer` with an `JtaTransactionManager`) to cover the -reception of the JMS message as well as the execution of the business logic in your +receipt of the JMS message as well as the execution of the business logic in your message listener (including database operations, etc.). IMPORTANT: The default `AUTO_ACKNOWLEDGE` mode does not provide proper reliability guarantees. diff --git a/framework-docs/modules/ROOT/pages/integration/jmx/exporting.adoc b/framework-docs/modules/ROOT/pages/integration/jmx/exporting.adoc index 2b1fb8663744..138c867a20c3 100644 --- a/framework-docs/modules/ROOT/pages/integration/jmx/exporting.adoc +++ b/framework-docs/modules/ROOT/pages/integration/jmx/exporting.adoc @@ -5,63 +5,13 @@ The core class in Spring's JMX framework is the `MBeanExporter`. This class is responsible for taking your Spring beans and registering them with a JMX `MBeanServer`. For example, consider the following class: -[source,java,indent=0,subs="verbatim,quotes",chomp="-packages",chomp="-packages"] ----- - package org.springframework.jmx; - - public class JmxTestBean implements IJmxTestBean { - - private String name; - private int age; - private boolean isSuperman; - - public int getAge() { - return age; - } - - public void setAge(int age) { - this.age = age; - } - - public void setName(String name) { - this.name = name; - } - - public String getName() { - return name; - } - - public int add(int x, int y) { - return x + y; - } - - public void dontExposeMe() { - throw new RuntimeException(); - } - } ----- +include-code::./JmxTestBean[tag=snippet,indent=0] To expose the properties and methods of this bean as attributes and operations of an MBean, you can configure an instance of the `MBeanExporter` class in your configuration file and pass in the bean, as the following example shows: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - - - - - - ----- +include-code::./JmxConfiguration[tag=snippet,indent=0] The pertinent bean definition from the preceding configuration snippet is the `exporter` bean. The `beans` property tells the `MBeanExporter` exactly which of your beans must be diff --git a/framework-docs/modules/ROOT/pages/integration/jmx/interface.adoc b/framework-docs/modules/ROOT/pages/integration/jmx/interface.adoc index f9458765aac1..68e1c25206b0 100644 --- a/framework-docs/modules/ROOT/pages/integration/jmx/interface.adoc +++ b/framework-docs/modules/ROOT/pages/integration/jmx/interface.adoc @@ -11,10 +11,10 @@ controlling the management interfaces of your beans. [[jmx-interface-assembler]] -== Using the `MBeanInfoAssembler` Interface +== Using the `MBeanInfoAssembler` API Behind the scenes, the `MBeanExporter` delegates to an implementation of the -`org.springframework.jmx.export.assembler.MBeanInfoAssembler` interface, which is +`org.springframework.jmx.export.assembler.MBeanInfoAssembler` API, which is responsible for defining the management interface of each bean that is exposed. The default implementation, `org.springframework.jmx.export.assembler.SimpleReflectiveMBeanInfoAssembler`, @@ -28,35 +28,31 @@ or any arbitrary interface. [[jmx-interface-metadata]] == Using Source-level Metadata: Java Annotations -By using the `MetadataMBeanInfoAssembler`, you can define the management interfaces -for your beans by using source-level metadata. The reading of metadata is encapsulated -by the `org.springframework.jmx.export.metadata.JmxAttributeSource` interface. -Spring JMX provides a default implementation that uses Java annotations, namely -`org.springframework.jmx.export.annotation.AnnotationJmxAttributeSource`. -You must configure the `MetadataMBeanInfoAssembler` with an implementation instance of -the `JmxAttributeSource` interface for it to function correctly (there is no default). +By using the `MetadataMBeanInfoAssembler`, you can define the management interfaces for +your beans by using source-level metadata. The reading of metadata is encapsulated by the +`org.springframework.jmx.export.metadata.JmxAttributeSource` interface. Spring JMX +provides a default implementation that uses Java annotations, namely +`org.springframework.jmx.export.annotation.AnnotationJmxAttributeSource`. You must +configure the `MetadataMBeanInfoAssembler` with an implementation instance of the +`JmxAttributeSource` interface for it to function correctly, since there is no default. To mark a bean for export to JMX, you should annotate the bean class with the -`ManagedResource` annotation. You must mark each method you wish to expose as an operation -with the `ManagedOperation` annotation and mark each property you wish to expose -with the `ManagedAttribute` annotation. When marking properties, you can omit +`@ManagedResource` annotation. You must annotate each method you wish to expose as an +operation with the `@ManagedOperation` annotation and annotate each property you wish to +expose with the `@ManagedAttribute` annotation. When annotating properties, you can omit either the annotation of the getter or the setter to create a write-only or read-only attribute, respectively. -NOTE: A `ManagedResource`-annotated bean must be public, as must the methods exposing -an operation or an attribute. +NOTE: A `@ManagedResource`-annotated bean must be public, as must the methods exposing +operations or attributes. -The following example shows the annotated version of the `JmxTestBean` class that we -used in xref:integration/jmx/exporting.adoc#jmx-exporting-mbeanserver[Creating an MBeanServer]: +The following example shows an annotated version of the `JmxTestBean` class that we +used in xref:integration/jmx/exporting.adoc#jmx-exporting-mbeanserver[Creating an MBeanServer]. [source,java,indent=0,subs="verbatim,quotes",chomp="-packages"] ---- package org.springframework.jmx; - import org.springframework.jmx.export.annotation.ManagedResource; - import org.springframework.jmx.export.annotation.ManagedOperation; - import org.springframework.jmx.export.annotation.ManagedAttribute; - @ManagedResource( objectName="bean:name=testBean4", description="My Managed Bean", @@ -67,20 +63,20 @@ used in xref:integration/jmx/exporting.adoc#jmx-exporting-mbeanserver[Creating a persistPeriod=200, persistLocation="foo", persistName="bar") - public class AnnotationTestBean implements IJmxTestBean { + public class AnnotationTestBean { - private String name; private int age; - - @ManagedAttribute(description="The Age Attribute", currencyTimeLimit=15) - public int getAge() { - return age; - } + private String name; public void setAge(int age) { this.age = age; } + @ManagedAttribute(description="The Age Attribute", currencyTimeLimit=15) + public int getAge() { + return this.age; + } + @ManagedAttribute(description="The Name Attribute", currencyTimeLimit=20, defaultValue="bar", @@ -91,13 +87,12 @@ used in xref:integration/jmx/exporting.adoc#jmx-exporting-mbeanserver[Creating a @ManagedAttribute(defaultValue="foo", persistPeriod=300) public String getName() { - return name; + return this.name; } @ManagedOperation(description="Add two numbers") - @ManagedOperationParameters({ - @ManagedOperationParameter(name = "x", description = "The first number"), - @ManagedOperationParameter(name = "y", description = "The second number")}) + @ManagedOperationParameter(name = "x", description = "The first number") + @ManagedOperationParameter(name = "y", description = "The second number") public int add(int x, int y) { return x + y; } @@ -109,36 +104,37 @@ used in xref:integration/jmx/exporting.adoc#jmx-exporting-mbeanserver[Creating a } ---- -In the preceding example, you can see that the `JmxTestBean` class is marked with the -`ManagedResource` annotation and that this `ManagedResource` annotation is configured -with a set of properties. These properties can be used to configure various aspects +In the preceding example, you can see that the `AnnotationTestBean` class is annotated +with `@ManagedResource` and that this `@ManagedResource` annotation is configured +with a set of attributes. These attributes can be used to configure various aspects of the MBean that is generated by the `MBeanExporter` and are explained in greater -detail later in xref:integration/jmx/interface.adoc#jmx-interface-metadata-types[Source-level Metadata Types]. +detail later in xref:integration/jmx/interface.adoc#jmx-interface-metadata-types[Spring JMX Annotations]. -Both the `age` and `name` properties are annotated with the `ManagedAttribute` -annotation, but, in the case of the `age` property, only the getter is marked. +Both the `age` and `name` properties are annotated with `@ManagedAttribute`, +but, in the case of the `age` property, only the getter method is annotated. This causes both of these properties to be included in the management interface -as attributes, but the `age` attribute is read-only. +as managed attributes, but the `age` attribute is read-only. -Finally, the `add(int, int)` method is marked with the `ManagedOperation` attribute, +Finally, the `add(int, int)` method is annotated with `@ManagedOperation`, whereas the `dontExposeMe()` method is not. This causes the management interface to contain only one operation (`add(int, int)`) when you use the `MetadataMBeanInfoAssembler`. +NOTE: The `AnnotationTestBean` class is not required to implement any Java interfaces, +since the JMX management interface is derived solely from annotations. + The following configuration shows how you can configure the `MBeanExporter` to use the `MetadataMBeanInfoAssembler`: [source,xml,indent=0,subs="verbatim,quotes"] ---- + - - @@ -151,102 +147,116 @@ The following configuration shows how you can configure the `MBeanExporter` to u + + + ---- -In the preceding example, an `MetadataMBeanInfoAssembler` bean has been configured with an +In the preceding example, a `MetadataMBeanInfoAssembler` bean has been configured with an instance of the `AnnotationJmxAttributeSource` class and passed to the `MBeanExporter` through the assembler property. This is all that is required to take advantage of -metadata-driven management interfaces for your Spring-exposed MBeans. +annotation-driven management interfaces for your Spring-exposed MBeans. [[jmx-interface-metadata-types]] -== Source-level Metadata Types +== Spring JMX Annotations -The following table describes the source-level metadata types that are available for use in Spring JMX: +The following table describes the annotations that are available for use in Spring JMX: [[jmx-metadata-types]] -.Source-level metadata types +.Spring JMX annotations +[cols="1,1,3"] |=== -| Purpose| Annotation| Annotation Type +| Annotation | Applies to | Description -| Mark all instances of a `Class` as JMX managed resources. | `@ManagedResource` -| Class +| Classes +| Marks all instances of a `Class` as JMX managed resources. -| Mark a method as a JMX operation. -| `@ManagedOperation` -| Method +| `@ManagedNotification` +| Classes +| Indicates a JMX notification emitted by a managed resource. -| Mark a getter or setter as one half of a JMX attribute. | `@ManagedAttribute` -| Method (only getters and setters) +| Methods (only getters and setters) +| Marks a getter or setter as one half of a JMX attribute. -| Define descriptions for operation parameters. -| `@ManagedOperationParameter` and `@ManagedOperationParameters` -| Method +| `@ManagedMetric` +| Methods (only getters) +| Marks a getter as a JMX attribute, with added descriptor properties to indicate that it is a metric. + +| `@ManagedOperation` +| Methods +| Marks a method as a JMX operation. + +| `@ManagedOperationParameter` +| Methods +| Defines a description for an operation parameter. |=== -The following table describes the configuration parameters that are available for use on these source-level -metadata types: +The following table describes some of the common attributes that are available for use in +these annotations. Consult the Javadoc for each annotation for further details. [[jmx-metadata-parameters]] -.Source-level metadata parameters -[cols="1,3,1"] +.Spring JMX annotation attributes +[cols="1,1,3"] |=== -| Parameter | Description | Applies to +| Attribute | Applies to | Description -| `ObjectName` +| `objectName` +| `@ManagedResource` | Used by `MetadataNamingStrategy` to determine the `ObjectName` of a managed resource. -| `ManagedResource` | `description` -| Sets the friendly description of the resource, attribute or operation. -| `ManagedResource`, `ManagedAttribute`, `ManagedOperation`, or `ManagedOperationParameter` +| `@ManagedResource`, `@ManagedNotification`, `@ManagedAttribute`, `@ManagedMetric`, + `@ManagedOperation`, `@ManagedOperationParameter` +| Sets the description of the resource, notification, attribute, metric, or operation. | `currencyTimeLimit` +| `@ManagedResource`, `@ManagedAttribute`, `@ManagedMetric` | Sets the value of the `currencyTimeLimit` descriptor field. -| `ManagedResource` or `ManagedAttribute` | `defaultValue` +| `@ManagedAttribute` | Sets the value of the `defaultValue` descriptor field. -| `ManagedAttribute` | `log` +| `@ManagedResource` | Sets the value of the `log` descriptor field. -| `ManagedResource` | `logFile` +| `@ManagedResource` | Sets the value of the `logFile` descriptor field. -| `ManagedResource` | `persistPolicy` +| `@ManagedResource`, `@ManagedMetric` | Sets the value of the `persistPolicy` descriptor field. -| `ManagedResource` | `persistPeriod` +| `@ManagedResource`, `@ManagedMetric` | Sets the value of the `persistPeriod` descriptor field. -| `ManagedResource` | `persistLocation` +| `@ManagedResource` | Sets the value of the `persistLocation` descriptor field. -| `ManagedResource` | `persistName` +| `@ManagedResource` | Sets the value of the `persistName` descriptor field. -| `ManagedResource` | `name` +| `@ManagedOperationParameter` | Sets the display name of an operation parameter. -| `ManagedOperationParameter` | `index` +| `@ManagedOperationParameter` | Sets the index of an operation parameter. -| `ManagedOperationParameter` |=== @@ -255,14 +265,14 @@ metadata types: To simplify configuration even further, Spring includes the `AutodetectCapableMBeanInfoAssembler` interface, which extends the `MBeanInfoAssembler` -interface to add support for autodetection of MBean resources. If you configure the +interface to add support for auto-detection of MBean resources. If you configure the `MBeanExporter` with an instance of `AutodetectCapableMBeanInfoAssembler`, it is -allowed to "`vote`" on the inclusion of beans for exposure to JMX. +allowed to "vote" on the inclusion of beans for exposure to JMX. The only implementation of the `AutodetectCapableMBeanInfo` interface is the `MetadataMBeanInfoAssembler`, which votes to include any bean that is marked with the `ManagedResource` attribute. The default approach in this case is to use the -bean name as the `ObjectName`, which results in a configuration similar to the following: +bean name as the `ObjectName`, which results in configuration similar to the following: [source,xml,indent=0,subs="verbatim,quotes"] ---- @@ -274,26 +284,29 @@ bean name as the `ObjectName`, which results in a configuration similar to the f - - - - - + + + + + ---- Notice that, in the preceding configuration, no beans are passed to the `MBeanExporter`. -However, the `JmxTestBean` is still registered, since it is marked with the `ManagedResource` -attribute and the `MetadataMBeanInfoAssembler` detects this and votes to include it. -The only problem with this approach is that the name of the `JmxTestBean` now has business -meaning. You can address this issue by changing the default behavior for `ObjectName` -creation as defined in xref:integration/jmx/naming.adoc[Controlling `ObjectName` Instances for Your Beans]. +However, the `AnnotationTestBean` is still registered, since it is annotated with +`@ManagedResource` and the `MetadataMBeanInfoAssembler` detects this and votes to include +it. The only downside with this approach is that the name of the `AnnotationTestBean` now +has business meaning. You can address this issue by configuring an `ObjectNamingStrategy` +as explained in xref:integration/jmx/naming.adoc[Controlling `ObjectName` Instances for +Your Beans]. You can also see an example which uses the `MetadataNamingStrategy` in +xref:integration/jmx/interface.adoc#jmx-interface-metadata[Using Source-level Metadata: Java Annotations]. + [[jmx-interface-java]] diff --git a/framework-docs/modules/ROOT/pages/integration/jmx/naming.adoc b/framework-docs/modules/ROOT/pages/integration/jmx/naming.adoc index 39b958281900..cdaf80432237 100644 --- a/framework-docs/modules/ROOT/pages/integration/jmx/naming.adoc +++ b/framework-docs/modules/ROOT/pages/integration/jmx/naming.adoc @@ -122,25 +122,10 @@ your management interfaces, a convenience subclass of `MBeanExporter` is availab `namingStrategy`, `assembler`, and `attributeSource` configuration, since it always uses standard Java annotation-based metadata (autodetection is always enabled as well). In fact, rather than defining an `MBeanExporter` bean, an even -simpler syntax is supported by the `@EnableMBeanExport` `@Configuration` annotation, -as the following example shows: +simpler syntax is supported by the `@EnableMBeanExport` `@Configuration` annotation or the `` +element as the following example shows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableMBeanExport - public class AppConfig { - - } ----- - -If you prefer XML-based configuration, the `` element serves the -same purpose and is shown in the following listing: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - ----- +include-code::./JmxConfiguration[tag=snippet,indent=0] If necessary, you can provide a reference to a particular MBean `server`, and the `defaultDomain` attribute (a property of `AnnotationMBeanExporter`) accepts an alternate @@ -148,21 +133,7 @@ value for the generated MBean `ObjectName` domains. This is used in place of the fully qualified package name as described in the previous section on xref:integration/jmx/naming.adoc#jmx-naming-metadata[MetadataNamingStrategy], as the following example shows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @EnableMBeanExport(server="myMBeanServer", defaultDomain="myDomain") - @Configuration - ContextConfiguration { - - } ----- - -The following example shows the XML equivalent of the preceding annotation-based example: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - ----- +include-code::./CustomJmxConfiguration[tag=snippet,indent=0] CAUTION: Do not use interface-based AOP proxies in combination with autodetection of JMX annotations in your bean classes. Interface-based proxies "`hide`" the target class, which diff --git a/framework-docs/modules/ROOT/pages/integration/observability.adoc b/framework-docs/modules/ROOT/pages/integration/observability.adoc index 28fe88fdbc3c..54cfa9e03bbc 100644 --- a/framework-docs/modules/ROOT/pages/integration/observability.adoc +++ b/framework-docs/modules/ROOT/pages/integration/observability.adoc @@ -7,7 +7,7 @@ Metrics can help you to track error rates, usage patterns, performance, and more Traces provide a holistic view of an entire system, crossing application boundaries; you can zoom in on particular user requests and follow their entire completion across applications. Spring Framework instruments various parts of its own codebase to publish observations if an `ObservationRegistry` is configured. -You can learn more about {spring-boot-docs}/actuator.html#actuator.metrics[configuring the observability infrastructure in Spring Boot]. +You can learn more about {spring-boot-docs-ref}/actuator/observability.html[configuring the observability infrastructure in Spring Boot]. [[observability.list]] @@ -37,7 +37,7 @@ As outlined xref:integration/observability.adoc[at the beginning of this section |Processing time for an execution of a `@Scheduled` task |=== -NOTE: Observations are using Micrometer's official naming convention, but Metrics names will be automatically converted +NOTE: Observations use Micrometer's official naming convention, but Metrics names will be automatically converted {micrometer-docs}/concepts/naming.html[to the format preferred by the monitoring system backend] (Prometheus, Atlas, Graphite, InfluxDB...). @@ -97,7 +97,7 @@ This can be done by declaring a `SchedulingConfigurer` bean that sets the observ include-code::./ObservationSchedulingConfigurer[] -It is using the `org.springframework.scheduling.support.DefaultScheduledTaskObservationConvention` by default, backed by the `ScheduledTaskObservationContext`. +It uses the `org.springframework.scheduling.support.DefaultScheduledTaskObservationConvention` by default, backed by the `ScheduledTaskObservationContext`. You can configure a custom implementation on the `ObservationRegistry` directly. During the execution of the scheduled method, the current observation is restored in the `ThreadLocal` context or the Reactor context (if the scheduled method returns a `Mono` or `Flux` type). @@ -107,7 +107,7 @@ By default, the following `KeyValues` are created: [cols="a,a"] |=== |Name | Description -|`code.function` _(required)_|Name of Java `Method` that is scheduled for execution. +|`code.function` _(required)_|Name of the Java `Method` that is scheduled for execution. |`code.namespace` _(required)_|Canonical name of the class of the bean instance that holds the scheduled method, or `"ANONYMOUS"` for anonymous classes. |`error` _(required)_|Class name of the exception thrown during the execution, or `"none"` if no exception happened. |`exception` _(deprecated)_|Duplicates the `error` key and might be removed in the future. @@ -126,7 +126,7 @@ This instrumentation will create 2 types of observations: * `"jms.message.publish"` when a JMS message is sent to the broker, typically with `JmsTemplate`. * `"jms.message.process"` when a JMS message is processed by the application, typically with a `MessageListener` or a `@JmsListener` annotated method. -NOTE: currently there is no instrumentation for `"jms.message.receive"` observations as there is little value in measuring the time spent waiting for the reception of a message. +NOTE: Currently there is no instrumentation for `"jms.message.receive"` observations as there is little value in measuring the time spent waiting for the receipt of a message. Such an integration would typically instrument `MessageConsumer#receive` method calls. But once those return, the processing time is not measured and the trace scope cannot be propagated to the application. By default, both observations share the same set of possible `KeyValues`: @@ -138,7 +138,7 @@ By default, both observations share the same set of possible `KeyValues`: |`error` |Class name of the exception thrown during the messaging operation (or "none"). |`exception` _(deprecated)_|Duplicates the `error` key and might be removed in the future. |`messaging.destination.temporary` _(required)_|Whether the destination is a `TemporaryQueue` or `TemporaryTopic` (values: `"true"` or `"false"`). -|`messaging.operation` _(required)_|Name of JMS operation being performed (values: `"publish"` or `"process"`). +|`messaging.operation` _(required)_|Name of the JMS operation being performed (values: `"publish"` or `"process"`). |=== .High cardinality Keys @@ -146,7 +146,7 @@ By default, both observations share the same set of possible `KeyValues`: |=== |Name | Description |`messaging.message.conversation_id` |The correlation ID of the JMS message. -|`messaging.destination.name` |The name of destination the current message was sent to. +|`messaging.destination.name` |The name of the destination the current message was sent to. |`messaging.message.id` |Value used by the messaging system as an identifier for the message. |=== @@ -213,7 +213,7 @@ By default, the following `KeyValues` are created: |Name | Description |`error` _(required)_|Class name of the exception thrown during the exchange, or `"none"` if no exception happened. |`exception` _(deprecated)_|Duplicates the `error` key and might be removed in the future. -|`method` _(required)_|Name of HTTP request method or `"none"` if not a well-known method. +|`method` _(required)_|Name of the HTTP request method or `"none"` if not a well-known method. |`outcome` _(required)_|Outcome of the HTTP server exchange. |`status` _(required)_|HTTP response raw status code, or `"UNKNOWN"` if no response was created. |`uri` _(required)_|URI pattern for the matching handler if available, falling back to `REDIRECTION` for 3xx responses, `NOT_FOUND` for 404 responses, `root` for requests with no path info, and `UNKNOWN` for all other requests. @@ -235,10 +235,10 @@ This can be done on the `WebHttpHandlerBuilder`, as follows: include-code::./HttpHandlerConfiguration[] -It is using the `org.springframework.http.server.reactive.observation.DefaultServerRequestObservationConvention` by default, backed by the `ServerRequestObservationContext`. +It uses the `org.springframework.http.server.reactive.observation.DefaultServerRequestObservationConvention` by default, backed by the `ServerRequestObservationContext`. This will only record an observation as an error if the `Exception` has not been handled by an application Controller. -Typically, all exceptions handled by Spring WebFlux's `@ExceptionHandler` and <> will not be recorded with the observation. +Typically, all exceptions handled by Spring WebFlux's `@ExceptionHandler` and xref:web/webflux/ann-rest-exceptions.adoc[`ProblemDetail` support] will not be recorded with the observation. You can, at any point during request processing, set the error field on the `ObservationContext` yourself: include-code::./UserController[] @@ -251,7 +251,7 @@ By default, the following `KeyValues` are created: |Name | Description |`error` _(required)_|Class name of the exception thrown during the exchange, or `"none"` if no exception happened. |`exception` _(deprecated)_|Duplicates the `error` key and might be removed in the future. -|`method` _(required)_|Name of HTTP request method or `"none"` if not a well-known method. +|`method` _(required)_|Name of the HTTP request method or `"none"` if not a well-known method. |`outcome` _(required)_|Outcome of the HTTP server exchange. |`status` _(required)_|HTTP response raw status code, or `"UNKNOWN"` if no response was created. |`uri` _(required)_|URI pattern for the matching handler if available, falling back to `REDIRECTION` for 3xx responses, `NOT_FOUND` for 404 responses, `root` for requests with no path info, and `UNKNOWN` for all other requests. @@ -270,6 +270,7 @@ By default, the following `KeyValues` are created: == HTTP Client Instrumentation HTTP client exchange observations are created with the name `"http.client.requests"` for blocking and reactive clients. +This observation measures the entire HTTP request/response exchange, from connection establishment up to body deserialization. Unlike their server counterparts, the instrumentation is implemented directly in the client so the only required step is to configure an `ObservationRegistry` on the client. [[observability.http-client.resttemplate]] @@ -284,8 +285,8 @@ Instrumentation uses the `org.springframework.http.client.observation.ClientRequ [cols="a,a"] |=== |Name | Description -|`method` _(required)_|Name of HTTP request method or `"none"` if not a well-known method. -|`uri` _(required)_|URI template used for HTTP request, or `"none"` if none was provided. Only the path part of the URI is considered. +|`method` _(required)_|Name of the HTTP request method or `"none"` if not a well-known method. +|`uri` _(required)_|URI template used for HTTP request, or `"none"` if none was provided. The protocol, host and port part of the URI are not considered. |`client.name` _(required)_|Client name derived from the request URI host. |`status` _(required)_|HTTP response raw status code, or `"IO_ERROR"` in case of `IOException`, or `"CLIENT_ERROR"` if no response was received. |`outcome` _(required)_|Outcome of the HTTP client exchange. @@ -312,8 +313,8 @@ Instrumentation uses the `org.springframework.http.client.observation.ClientRequ [cols="a,a"] |=== |Name | Description -|`method` _(required)_|Name of HTTP request method or `"none"` if the request could not be created. -|`uri` _(required)_|URI template used for HTTP request, or `"none"` if none was provided. Only the path part of the URI is considered. +|`method` _(required)_|Name of the HTTP request method or `"none"` if the request could not be created. +|`uri` _(required)_|URI template used for HTTP request, or `"none"` if none was provided. The protocol, host and port part of the URI are not considered. |`client.name` _(required)_|Client name derived from the request URI host. |`status` _(required)_|HTTP response raw status code, or `"IO_ERROR"` in case of `IOException`, or `"CLIENT_ERROR"` if no response was received. |`outcome` _(required)_|Outcome of the HTTP client exchange. @@ -332,7 +333,7 @@ Instrumentation uses the `org.springframework.http.client.observation.ClientRequ [[observability.http-client.webclient]] === WebClient -Applications must configure an `ObservationRegistry` on the `WebClient` builder to enable the instrumentation; without that, observations are "no-ops". +Applications must configure an `ObservationRegistry` on the `WebClient.Builder` to enable the instrumentation; without that, observations are "no-ops". Spring Boot will auto-configure `WebClient.Builder` beans with the observation registry already set. Instrumentation uses the `org.springframework.web.reactive.function.client.ClientRequestObservationConvention` by default, backed by the `ClientRequestObservationContext`. @@ -341,8 +342,8 @@ Instrumentation uses the `org.springframework.web.reactive.function.client.Clien [cols="a,a"] |=== |Name | Description -|`method` _(required)_|Name of HTTP request method or `"none"` if not a well-known method. -|`uri` _(required)_|URI template used for HTTP request, or `"none"` if none was provided. Only the path part of the URI is considered. +|`method` _(required)_|Name of the HTTP request method or `"none"` if not a well-known method. +|`uri` _(required)_|URI template used for HTTP request, or `"none"` if none was provided. The protocol, host and port part of the URI are not considered. |`client.name` _(required)_|Client name derived from the request URI host. |`status` _(required)_|HTTP response raw status code, or `"IO_ERROR"` in case of `IOException`, or `"CLIENT_ERROR"` if no response was received. |`outcome` _(required)_|Outcome of the HTTP client exchange. diff --git a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc index 0e0c5d2de63b..94a163f4ab89 100644 --- a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc +++ b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc @@ -28,7 +28,7 @@ The following sample shows how to create a default `RestClient`, and how to buil ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- RestClient defaultClient = RestClient.create(); @@ -38,6 +38,7 @@ RestClient customClient = RestClient.builder() .baseUrl("https://example.com") .defaultUriVariables(Map.of("variable", "foo")) .defaultHeader("My-Header", "Foo") + .defaultCookie("My-Cookie", "Bar") .requestInterceptor(myCustomInterceptor) .requestInitializer(myCustomInitializer) .build(); @@ -45,7 +46,7 @@ RestClient customClient = RestClient.builder() Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- val defaultClient = RestClient.create() @@ -55,6 +56,7 @@ val customClient = RestClient.builder() .baseUrl("https://example.com") .defaultUriVariables(mapOf("variable" to "foo")) .defaultHeader("My-Header", "Foo") + .defaultCookie("My-Cookie", "Bar") .requestInterceptor(myCustomInterceptor) .requestInitializer(myCustomInitializer) .build() @@ -77,7 +79,7 @@ The following example configures a GET request to `https://example.com/orders/42 ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- int id = 42; restClient.get() @@ -87,7 +89,7 @@ restClient.get() Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val id = 42 restClient.get() @@ -113,11 +115,15 @@ Finally, the body can be set to a callback function that writes to an `OutputStr ==== Retrieving the response -Once the request has been set up, the HTTP response is accessed by invoking `retrieve()`. -The response body can be accessed by using `body(Class)` or `body(ParameterizedTypeReference)` for parameterized types like lists. +Once the request has been set up, it can be sent by chaining method calls after `retrieve()`. +For example, the response body can be accessed by using `retrieve().body(Class)` or `retrieve().body(ParameterizedTypeReference)` for parameterized types like lists. The `body` method converts the response contents into various types – for instance, bytes can be converted into a `String`, JSON can be converted into objects using Jackson, and so on (see <>). -The response can also be converted into a `ResponseEntity`, giving access to the response headers as well as the body. +The response can also be converted into a `ResponseEntity`, giving access to the response headers as well as the body, with `retrieve().toEntity(Class)` + +NOTE: Calling `retrieve()` by itself is a no-op and returns a `ResponseSpec`. +Applications must invoke a terminal operation on the `ResponseSpec` to have any side effect. +If consuming the response has no interest for your use case, you can use `retrieve().toBodilessEntity()`. This sample shows how `RestClient` can be used to perform a simple `GET` request. @@ -125,7 +131,7 @@ This sample shows how `RestClient` can be used to perform a simple `GET` request ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- String result = restClient.get() <1> .uri("https://example.com") <2> @@ -142,7 +148,7 @@ System.out.println(result); <5> Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val result= restClient.get() <1> .uri("https://example.com") <2> @@ -164,7 +170,7 @@ Access to the response status code and headers is provided through `ResponseEnti ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ResponseEntity result = restClient.get() <1> .uri("https://example.com") <1> @@ -181,7 +187,7 @@ System.out.println("Contents: " + result.getBody()); <3> Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val result = restClient.get() <1> .uri("https://example.com") <1> @@ -204,7 +210,7 @@ Note the usage of URI variables in this sample and that the `Accept` header is s ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- int id = ...; Pet pet = restClient.get() @@ -219,7 +225,7 @@ Pet pet = restClient.get() Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val id = ... val pet = restClient.get() @@ -239,7 +245,7 @@ In the next sample, `RestClient` is used to perform a POST request that contains ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Pet pet = ... <1> ResponseEntity response = restClient.post() <2> @@ -257,7 +263,7 @@ ResponseEntity response = restClient.post() <2> Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val pet: Pet = ... <1> val response = restClient.post() <2> @@ -283,7 +289,7 @@ This behavior can be overridden using `onStatus`. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- String result = restClient.get() <1> .uri("https://example.com/this-url-does-not-exist") <1> @@ -299,7 +305,7 @@ String result = restClient.get() <1> Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val result = restClient.get() <1> .uri("https://example.com/this-url-does-not-exist") <1> @@ -322,7 +328,7 @@ Status handlers are not applied when use `exchange()`, because the exchange func ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Pet result = restClient.get() .uri("https://petclinic.example.com/pets/{id}", id) @@ -343,7 +349,7 @@ Pet result = restClient.get() Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val result = restClient.get() .uri("https://petclinic.example.com/pets/{id}", id) @@ -366,68 +372,7 @@ val result = restClient.get() [[rest-message-conversion]] === HTTP Message Conversion -[.small]#xref:web/webflux/reactive-spring.adoc#webflux-codecs[See equivalent in the Reactive stack]# - -The `spring-web` module contains the `HttpMessageConverter` interface for reading and writing the body of HTTP requests and responses through `InputStream` and `OutputStream`. -`HttpMessageConverter` instances are used on the client side (for example, in the `RestClient`) and on the server side (for example, in Spring MVC REST controllers). - -Concrete implementations for the main media (MIME) types are provided in the framework and are, by default, registered with the `RestClient` and `RestTemplate` on the client side and with `RequestMappingHandlerAdapter` on the server side (see xref:web/webmvc/mvc-config/message-converters.adoc[Configuring Message Converters]). - -Several implementations of `HttpMessageConverter` are described below. -Refer to the {spring-framework-api}/http/converter/HttpMessageConverter.html[`HttpMessageConverter` Javadoc] for the complete list. -For all converters, a default media type is used, but you can override it by setting the `supportedMediaTypes` property. - -[[rest-message-converters-tbl]] -.HttpMessageConverter Implementations -[cols="1,3"] -|=== -| MessageConverter | Description - -| `StringHttpMessageConverter` -| An `HttpMessageConverter` implementation that can read and write `String` instances from the HTTP request and response. -By default, this converter supports all text media types(`text/{asterisk}`) and writes with a `Content-Type` of `text/plain`. - -| `FormHttpMessageConverter` -| An `HttpMessageConverter` implementation that can read and write form data from the HTTP request and response. -By default, this converter reads and writes the `application/x-www-form-urlencoded` media type. -Form data is read from and written into a `MultiValueMap`. -The converter can also write (but not read) multipart data read from a `MultiValueMap`. -By default, `multipart/form-data` is supported. -Additional multipart subtypes can be supported for writing form data. -Consult the javadoc for `FormHttpMessageConverter` for further details. - -| `ByteArrayHttpMessageConverter` -| An `HttpMessageConverter` implementation that can read and write byte arrays from the HTTP request and response. -By default, this converter supports all media types (`{asterisk}/{asterisk}`) and writes with a `Content-Type` of `application/octet-stream`. -You can override this by setting the `supportedMediaTypes` property and overriding `getContentType(byte[])`. - -| `MarshallingHttpMessageConverter` -| An `HttpMessageConverter` implementation that can read and write XML by using Spring's `Marshaller` and `Unmarshaller` abstractions from the `org.springframework.oxm` package. -This converter requires a `Marshaller` and `Unmarshaller` before it can be used. -You can inject these through constructor or bean properties. -By default, this converter supports `text/xml` and `application/xml`. - -| `MappingJackson2HttpMessageConverter` -| An `HttpMessageConverter` implementation that can read and write JSON by using Jackson's `ObjectMapper`. -You can customize JSON mapping as needed through the use of Jackson's provided annotations. -When you need further control (for cases where custom JSON serializers/deserializers need to be provided for specific types), you can inject a custom `ObjectMapper` through the `ObjectMapper` property. -By default, this converter supports `application/json`. - -| `MappingJackson2XmlHttpMessageConverter` -| An `HttpMessageConverter` implementation that can read and write XML by using {jackson-github-org}/jackson-dataformat-xml[Jackson XML] extension's `XmlMapper`. -You can customize XML mapping as needed through the use of JAXB or Jackson's provided annotations. -When you need further control (for cases where custom XML serializers/deserializers need to be provided for specific types), you can inject a custom `XmlMapper` through the `ObjectMapper` property. -By default, this converter supports `application/xml`. - -| `SourceHttpMessageConverter` -| An `HttpMessageConverter` implementation that can read and write `javax.xml.transform.Source` from the HTTP request and response. -Only `DOMSource`, `SAXSource`, and `StreamSource` are supported. -By default, this converter supports `text/xml` and `application/xml`. - -|=== - -By default, `RestClient` and `RestTemplate` register all built-in message converters, depending on the availability of underlying libraries on the classpath. -You can also set the message converters to use explicitly, by using the `messageConverters()` method on the `RestClient` builder, or via the `messageConverters` property of `RestTemplate`. +xref:web/webmvc/message-converters.adoc#message-converters[See the supported HTTP message converters in the dedicated section]. ==== Jackson JSON Views @@ -494,7 +439,7 @@ If no request factory is specified when the `RestClient` was built, it will use Otherwise, if the `java.net.http` module is loaded, it will use Java's `HttpClient`. Finally, it will resort to the simple default. -TIP: Note that the `SimpleClientHttpRequestFactory` may raise an exception when accessing the status of a response that represents an error (e.g. 401). +TIP: Note that the `SimpleClientHttpRequestFactory` may raise an exception when accessing the status of a response that represents an error (for example, 401). If this is an issue, use any of the alternative request factories. [[rest-webclient]] @@ -997,9 +942,10 @@ method parameters: | Dynamically set the HTTP method for the request, overriding the annotation's `method` attribute | `@RequestHeader` -| Add a request header or multiple headers. The argument may be a `Map` or - `MultiValueMap` with multiple headers, a `Collection` of values, or an - individual value. Type conversion is supported for non-String values. +| Add a request header or multiple headers. The argument may be a single value, + a `Collection` of values, `Map`,`MultiValueMap`. + Type conversion is supported for non-String values. Header values are added and + do not override already added header values. | `@PathVariable` | Add a variable for expand a placeholder in the request URL. The argument may be a @@ -1007,7 +953,8 @@ method parameters: is supported for non-String values. | `@RequestAttribute` -| Provide an `Object` to add as a request attribute. Only supported by `WebClient`. +| Provide an `Object` to add as a request attribute. Only supported by `RestClient` + and `WebClient`. | `@RequestBody` | Provide the body of the request either as an Object to be serialized, or a @@ -1025,7 +972,7 @@ method parameters: | `@RequestPart` | Add a request part, which may be a String (form field), `Resource` (file part), - Object (entity to be encoded, e.g. as JSON), `HttpEntity` (part content and headers), + Object (entity to be encoded, for example, as JSON), `HttpEntity` (part content and headers), a Spring `Part`, or Reactive Streams `Publisher` of any of the above. | `MultipartFile` @@ -1039,6 +986,32 @@ method parameters: |=== +Method parameters cannot be `null` unless the `required` attribute (where available on a +parameter annotation) is set to `false`, or the parameter is marked optional as determined by +{spring-framework-api}/core/MethodParameter.html#isOptional()[`MethodParameter#isOptional`]. + + + +[[rest-http-interface.custom-resolver]] +=== Custom argument resolver + +For more complex cases, HTTP interfaces do not support `RequestEntity` types as method parameters. +This would take over the entire HTTP request and not improve the semantics of the interface. +Instead of adding many method parameters, developers can combine them into a custom type +and configure a dedicated `HttpServiceArgumentResolver` implementation. + +In the following HTTP interface, we are using a custom `Search` type as a parameter: + +include-code::./CustomHttpServiceArgumentResolver[tag=httpinterface,indent=0] + +We can implement our own `HttpServiceArgumentResolver` that supports our custom `Search` type +and writes its data in the outgoing HTTP request. + +include-code::./CustomHttpServiceArgumentResolver[tag=argumentresolver,indent=0] + +Finally, we can use this argument resolver during the setup and use our HTTP interface. + +include-code::./CustomHttpServiceArgumentResolver[tag=usage,indent=0] [[rest-http-interface-return-values]] === Return Values diff --git a/framework-docs/modules/ROOT/pages/integration/scheduling.adoc b/framework-docs/modules/ROOT/pages/integration/scheduling.adoc index c57059aa3fc3..a031ee208f81 100644 --- a/framework-docs/modules/ROOT/pages/integration/scheduling.adoc +++ b/framework-docs/modules/ROOT/pages/integration/scheduling.adoc @@ -50,6 +50,9 @@ The variants that Spring provides are as follows: for each invocation. However, it does support a concurrency limit that blocks any invocations that are over the limit until a slot has been freed up. If you are looking for true pooling, see `ThreadPoolTaskExecutor`, later in this list. + This will use JDK 21's Virtual Threads, when the "virtualThreads" + option is enabled. This implementation also supports graceful shutdown through + Spring's lifecycle management. * `ConcurrentTaskExecutor`: This implementation is an adapter for a `java.util.concurrent.Executor` instance. There is an alternative (`ThreadPoolTaskExecutor`) that exposes the `Executor` @@ -61,15 +64,13 @@ The variants that Spring provides are as follows: a `java.util.concurrent.ThreadPoolExecutor` and wraps it in a `TaskExecutor`. If you need to adapt to a different kind of `java.util.concurrent.Executor`, we recommend that you use a `ConcurrentTaskExecutor` instead. + It also provides a pause/resume capability and graceful shutdown through + Spring's lifecycle management. * `DefaultManagedTaskExecutor`: This implementation uses a JNDI-obtained `ManagedExecutorService` in a JSR-236 compatible runtime environment (such as a Jakarta EE application server), replacing a CommonJ WorkManager for that purpose. -As of 6.1, `ThreadPoolTaskExecutor` provides a pause/resume capability and graceful -shutdown through Spring's lifecycle management. There is also a new "virtualThreads" -option on `SimpleAsyncTaskExecutor` which is aligned with JDK 21's Virtual Threads, -as well as a graceful shutdown capability for `SimpleAsyncTaskExecutor` as well. [[scheduling-task-executor-usage]] @@ -79,38 +80,7 @@ Spring's `TaskExecutor` implementations are commonly used with dependency inject In the following example, we define a bean that uses the `ThreadPoolTaskExecutor` to asynchronously print out a set of messages: -[source,java,indent=0,subs="verbatim,quotes"] ----- - import org.springframework.core.task.TaskExecutor; - - public class TaskExecutorExample { - - private class MessagePrinterTask implements Runnable { - - private String message; - - public MessagePrinterTask(String message) { - this.message = message; - } - - public void run() { - System.out.println(message); - } - } - - private TaskExecutor taskExecutor; - - public TaskExecutorExample(TaskExecutor taskExecutor) { - this.taskExecutor = taskExecutor; - } - - public void printMessages() { - for(int i = 0; i < 25; i++) { - taskExecutor.execute(new MessagePrinterTask("Message" + i)); - } - } - } ----- +include-code::./TaskExecutorExample[tag=snippet,indent=0] As you can see, rather than retrieving a thread from the pool and executing it yourself, you add your `Runnable` to the queue. Then the `TaskExecutor` uses its internal rules to @@ -118,19 +88,23 @@ decide when the task gets run. To configure the rules that the `TaskExecutor` uses, we expose simple bean properties: -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - +include-code::./TaskExecutorConfiguration[tag=snippet,indent=0] - - - ----- +Most `TaskExecutor` implementations provide a way to automatically wrap tasks submitted +with a `TaskDecorator`. Decorators should delegate to the task it is wrapping, possibly +implementing custom behavior before/after the execution of the task. + +Let's consider a simple implementation that will log messages before and after the execution +or our tasks: + +include-code::./LoggingTaskDecorator[indent=0] +We can then configure our decorator on a `TaskExecutor` instance: + +include-code::./TaskExecutorConfiguration[tag=decorator,indent=0] + +In case multiple decorators are needed, the `org.springframework.core.task.support.CompositeTaskDecorator` +can be used to execute sequentially multiple decorators. [[scheduling-task-scheduler]] @@ -269,16 +243,10 @@ execution. === Enable Scheduling Annotations To enable support for `@Scheduled` and `@Async` annotations, you can add `@EnableScheduling` -and `@EnableAsync` to one of your `@Configuration` classes, as the following example shows: +and `@EnableAsync` to one of your `@Configuration` classes, or `` element, +as the following example shows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableAsync - @EnableScheduling - public class AppConfig { - } ----- +include-code::./SchedulingConfiguration[tag=snippet,indent=0] You can pick and choose the relevant annotations for your application. For example, if you need only support for `@Scheduled`, you can omit `@EnableAsync`. For more @@ -288,16 +256,6 @@ interface, the `AsyncConfigurer` interface, or both. See the and {spring-framework-api}/scheduling/annotation/AsyncConfigurer.html[`AsyncConfigurer`] javadoc for full details. -If you prefer XML configuration, you can use the `` element, -as the following example shows: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - ----- - Note that, with the preceding XML, an executor reference is provided for handling those tasks that correspond to methods with the `@Async` annotation, and the scheduler reference is provided for managing those methods annotated with `@Scheduled`. @@ -525,7 +483,7 @@ seconds: ==== When destroying the annotated bean or closing the application context, Spring Framework cancels scheduled tasks, which includes the next scheduled subscription to the `Publisher` as well -as any past subscription that is still currently active (e.g. for long-running publishers +as any past subscription that is still currently active (for example, for long-running publishers or even infinite publishers). ==== @@ -675,7 +633,7 @@ scheduled with a trigger. [[scheduling-task-namespace-scheduler]] -=== The 'scheduler' Element +=== The `scheduler` Element The following element creates a `ThreadPoolTaskScheduler` instance with the specified thread pool size: @@ -786,7 +744,7 @@ The following example sets the `keep-alive` value to two minutes: [[scheduling-task-namespace-scheduled-tasks]] -=== The 'scheduled-tasks' Element +=== The `scheduled-tasks` Element The most powerful feature of Spring's task namespace is the support for configuring tasks to be scheduled within a Spring Application Context. This follows an approach diff --git a/framework-docs/modules/ROOT/pages/languages/dynamic.adoc b/framework-docs/modules/ROOT/pages/languages/dynamic.adoc index fed4d8574e70..c0b5ccec7ca1 100644 --- a/framework-docs/modules/ROOT/pages/languages/dynamic.adoc +++ b/framework-docs/modules/ROOT/pages/languages/dynamic.adoc @@ -10,7 +10,7 @@ objects. Spring's scripting support primarily targets Groovy and BeanShell. Beyond those specifically supported languages, the JSR-223 scripting mechanism is supported for integration with any JSR-223 capable language provider (as of Spring 4.2), -e.g. JRuby. +for example, JRuby. You can find fully working examples of where this dynamic language support can be immediately useful in xref:languages/dynamic.adoc#dynamic-language-scenarios[Scenarios]. @@ -179,7 +179,7 @@ Each of the supported languages has a corresponding `` element: * `` (Groovy) * `` (BeanShell) -* `` (JSR-223, e.g. with JRuby) +* `` (JSR-223, for example, with JRuby) The exact attributes and child elements that are available for configuration depends on exactly which language the bean has been defined in (the language-specific sections diff --git a/framework-docs/modules/ROOT/pages/languages/kotlin.adoc b/framework-docs/modules/ROOT/pages/languages/kotlin.adoc index d373497009bc..41fbd6bb4f67 100644 --- a/framework-docs/modules/ROOT/pages/languages/kotlin.adoc +++ b/framework-docs/modules/ROOT/pages/languages/kotlin.adoc @@ -13,7 +13,7 @@ Most of the code samples of the reference documentation are provided in Kotlin in addition to Java. The easiest way to build a Spring application with Kotlin is to leverage Spring Boot and -its {spring-boot-docs}/boot-features-kotlin.html[dedicated Kotlin support]. +its {spring-boot-docs-ref}/features/kotlin.html[dedicated Kotlin support]. {spring-site-guides}/tutorials/spring-boot-kotlin/[This comprehensive tutorial] will teach you how to build Spring Boot applications with Kotlin using https://start.spring.io/#!language=kotlin&type=gradle-project[start.spring.io]. diff --git a/framework-docs/modules/ROOT/pages/languages/kotlin/annotations.adoc b/framework-docs/modules/ROOT/pages/languages/kotlin/annotations.adoc index 813d2c106b3b..1725a5dc98f2 100644 --- a/framework-docs/modules/ROOT/pages/languages/kotlin/annotations.adoc +++ b/framework-docs/modules/ROOT/pages/languages/kotlin/annotations.adoc @@ -14,16 +14,13 @@ For example, `@Autowired lateinit var thing: Thing` implies that a bean of type `Thing` must be registered in the application context, while `@Autowired lateinit var thing: Thing?` does not raise an error if such a bean does not exist. -Following the same principle, `@Bean fun play(toy: Toy, car: Car?) = Baz(toy, Car)` implies +Following the same principle, `@Bean fun play(toy: Toy, car: Car?) = Baz(toy, car)` implies that a bean of type `Toy` must be registered in the application context, while a bean of type `Car` may or may not exist. The same behavior applies to autowired constructor parameters. NOTE: If you use bean validation on classes with properties or a primary constructor -parameters, you may need to use +with parameters, you may need to use {kotlin-docs}/annotations.html#annotation-use-site-targets[annotation use-site targets], such as `@field:NotNull` or `@get:Size(min=5, max=15)`, as described in {stackoverflow-site}/a/35853200/1092077[this Stack Overflow response]. - - - diff --git a/framework-docs/modules/ROOT/pages/languages/kotlin/coroutines.adoc b/framework-docs/modules/ROOT/pages/languages/kotlin/coroutines.adoc index 913acb052e71..9e09a69411ab 100644 --- a/framework-docs/modules/ROOT/pages/languages/kotlin/coroutines.adoc +++ b/framework-docs/modules/ROOT/pages/languages/kotlin/coroutines.adoc @@ -209,7 +209,7 @@ class UserHandler(builder: WebClient.Builder) { == Transactions Transactions on Coroutines are supported via the programmatic variant of the Reactive -transaction management provided as of Spring Framework 5.2. +transaction management. For suspending functions, a `TransactionalOperator.executeAndAwait` extension is provided. diff --git a/framework-docs/modules/ROOT/pages/languages/kotlin/spring-projects-in.adoc b/framework-docs/modules/ROOT/pages/languages/kotlin/spring-projects-in.adoc index 300c0089c897..64da5a0b63ed 100644 --- a/framework-docs/modules/ROOT/pages/languages/kotlin/spring-projects-in.adoc +++ b/framework-docs/modules/ROOT/pages/languages/kotlin/spring-projects-in.adoc @@ -186,7 +186,7 @@ Therefore, if you wish to use the `@Value` annotation in Kotlin, you need to esc character by writing pass:q[`@Value("\${property}")`]. NOTE: If you use Spring Boot, you should probably use -{spring-boot-docs}/boot-features-external-config.html#boot-features-external-config-typesafe-configuration-properties[`@ConfigurationProperties`] +{spring-boot-docs-ref}/features/external-config.html#features.external-config.typesafe-configuration-properties[`@ConfigurationProperties`] instead of `@Value` annotations. As an alternative, you can customize the property placeholder prefix by declaring the @@ -323,7 +323,7 @@ The recommended testing framework is https://junit.org/junit5/[JUnit 5] along wi https://mockk.io/[Mockk] for mocking. NOTE: If you are using Spring Boot, see -{spring-boot-docs}/features.html#features.kotlin.testing[this related documentation]. +{spring-boot-docs-ref}/features/kotlin.html#features.kotlin.testing[this related documentation]. [[constructor-injection]] diff --git a/framework-docs/modules/ROOT/pages/overview.adoc b/framework-docs/modules/ROOT/pages/overview.adoc index cb03d79d9c0d..da80bf35094d 100644 --- a/framework-docs/modules/ROOT/pages/overview.adoc +++ b/framework-docs/modules/ROOT/pages/overview.adoc @@ -10,7 +10,7 @@ Spring requires Java 17+. Spring supports a wide range of application scenarios. In a large enterprise, applications often exist for a long time and have to run on a JDK and application server whose upgrade -cycle is beyond developer control. Others may run as a single jar with the server embedded, +cycle is beyond the developer's control. Others may run as a single jar with the server embedded, possibly in a cloud environment. Yet others may be standalone applications (such as batch or integration workloads) that do not need a server. @@ -37,12 +37,12 @@ support for different application architectures, including messaging, transactio persistence, and web. It also includes the Servlet-based Spring MVC web framework and, in parallel, the Spring WebFlux reactive web framework. -A note about modules: Spring's framework jars allow for deployment to JDK 9's module path -("Jigsaw"). For use in Jigsaw-enabled applications, the Spring Framework 5 jars come with -"Automatic-Module-Name" manifest entries which define stable language-level module names -("spring.core", "spring.context", etc.) independent from jar artifact names (the jars follow -the same naming pattern with "-" instead of ".", e.g. "spring-core" and "spring-context"). -Of course, Spring's framework jars keep working fine on the classpath on both JDK 8 and 9+. +A note about modules: Spring Framework's jars allow for deployment to the module path (Java +Module System). For use in module-enabled applications, the Spring Framework jars come with +`Automatic-Module-Name` manifest entries which define stable language-level module names +(`spring.core`, `spring.context`, etc.) independent from jar artifact names. The jars follow +the same naming pattern with `-` instead of `.` – for example, `spring-core` and `spring-context`. +Of course, Spring Framework's jars also work fine on the classpath. @@ -73,7 +73,7 @@ developers may choose to use instead of the Spring-specific mechanisms provided by the Spring Framework. Originally, those were based on common `javax` packages. As of Spring Framework 6.0, Spring has been upgraded to the Jakarta EE 9 level -(e.g. Servlet 5.0+, JPA 3.0+), based on the `jakarta` namespace instead of the +(for example, Servlet 5.0+, JPA 3.0+), based on the `jakarta` namespace instead of the traditional `javax` packages. With EE 9 as the minimum and EE 10 supported already, Spring is prepared to provide out-of-the-box support for the further evolution of the Jakarta EE APIs. Spring Framework 6.0 is fully compatible with Tomcat 10.1, diff --git a/framework-docs/modules/ROOT/pages/rsocket.adoc b/framework-docs/modules/ROOT/pages/rsocket.adoc index 402c213898df..52e42adf8001 100644 --- a/framework-docs/modules/ROOT/pages/rsocket.adoc +++ b/framework-docs/modules/ROOT/pages/rsocket.adoc @@ -113,7 +113,7 @@ a natural fit to use `Flux` and `Mono` with declarative operators and transparen pressure support. The API in RSocket Java is intentionally minimal and basic. It focuses on protocol -features and leaves the application programming model (e.g. RPC codegen vs other) as a +features and leaves the application programming model (for example, RPC codegen vs other) as a higher level, independent concern. The main contract @@ -186,7 +186,7 @@ This is the most basic way to connect with default settings: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RSocketRequester requester = RSocketRequester.builder().tcp("localhost", 7000); @@ -196,7 +196,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val requester = RSocketRequester.builder().tcp("localhost", 7000) @@ -244,7 +244,7 @@ can be registered as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RSocketStrategies strategies = RSocketStrategies.builder() .encoders(encoders -> encoders.add(new Jackson2CborEncoder())) @@ -258,7 +258,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val strategies = RSocketStrategies.builder() .encoders { it.add(Jackson2CborEncoder()) } @@ -271,7 +271,7 @@ Kotlin:: ---- ====== -`RSocketStrategies` is designed for re-use. In some scenarios, e.g. client and server in +`RSocketStrategies` is designed for re-use. In some scenarios, for example, client and server in the same application, it may be preferable to declare it in Spring configuration. @@ -288,7 +288,7 @@ infrastructure that's used on a server, but registered programmatically as follo ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RSocketStrategies strategies = RSocketStrategies.builder() .routeMatcher(new PathPatternRouteMatcher()) // <1> @@ -308,7 +308,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val strategies = RSocketStrategies.builder() .routeMatcher(PathPatternRouteMatcher()) // <1> @@ -335,7 +335,7 @@ you can still declare `RSocketMessageHandler` as a Spring bean and then apply as ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ApplicationContext context = ... ; RSocketMessageHandler handler = context.getBean(RSocketMessageHandler.class); @@ -347,7 +347,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.beans.factory.getBean @@ -361,7 +361,7 @@ Kotlin:: ====== For the above you may also need to use `setHandlerPredicate` in `RSocketMessageHandler` to -switch to a different strategy for detecting client responders, e.g. based on a custom +switch to a different strategy for detecting client responders, for example, based on a custom annotation such as `@RSocketClientResponder` vs the default `@Controller`. This is necessary in scenarios with client and server, or multiple clients in the same application. @@ -381,7 +381,7 @@ at that level as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RSocketRequester requester = RSocketRequester.builder() .rsocketConnector(connector -> { @@ -392,7 +392,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val requester = RSocketRequester.builder() .rsocketConnector { @@ -419,7 +419,7 @@ decoupled from handling. For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ConnectMapping Mono handle(RSocketRequester requester) { @@ -436,7 +436,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ConnectMapping suspend fun handle(requester: RSocketRequester) { @@ -464,7 +464,7 @@ xref:rsocket.adoc#rsocket-requester-server[server] requester, you can make reque ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ViewBox viewBox = ... ; @@ -479,7 +479,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val viewBox: ViewBox = ... @@ -516,7 +516,7 @@ The `data(Object)` step is optional. Skip it for requests that don't send data: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Mono location = requester.route("find.radar.EWR")) .retrieveMono(AirportLocation.class); @@ -524,7 +524,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.messaging.rsocket.retrieveAndAwait @@ -541,7 +541,7 @@ values are supported by a registered `Encoder`. For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- String securityToken = ... ; ViewBox viewBox = ... ; @@ -555,7 +555,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.messaging.rsocket.retrieveFlow @@ -600,7 +600,7 @@ methods: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration static class ServerConfig { @@ -616,7 +616,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class ServerConfig { @@ -636,7 +636,7 @@ Then start an RSocket server through the Java RSocket API and plug the ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ApplicationContext context = ... ; RSocketMessageHandler handler = context.getBean(RSocketMessageHandler.class); @@ -649,7 +649,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.beans.factory.getBean @@ -684,7 +684,7 @@ you need to share configuration between a client and a server in the same proces ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration static class ServerConfig { @@ -709,7 +709,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class ServerConfig { @@ -751,7 +751,7 @@ xref:rsocket.adoc#rsocket-annot-responders-client[client] responder configuratio ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller public class RadarsController { @@ -765,7 +765,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller class RadarsController { @@ -798,7 +798,7 @@ use the following method arguments: | Requester for making requests to the remote end. | `@DestinationVariable` -| Value extracted from the route based on variables in the mapping pattern, e.g. +| Value extracted from the route based on variables in the mapping pattern, for example, pass:q[`@MessageMapping("find.radar.{id}")`]. | `@Header` @@ -879,7 +879,7 @@ For example, to handle requests as a responder: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public interface RadarsService { @@ -898,7 +898,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- interface RadarsService { @@ -955,7 +955,7 @@ xref:rsocket.adoc#rsocket-requester-server[Server Requester] for details. Responders must interpret metadata. {rsocket-protocol-extensions}/CompositeMetadata.md[Composite metadata] allows independently -formatted metadata values (e.g. for routing, security, tracing) each with its own mime +formatted metadata values (for example, for routing, security, tracing) each with its own mime type. Applications need a way to configure metadata mime types to support, and a way to access extracted values. @@ -973,7 +973,7 @@ a `Decoder` and register the mime type as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- DefaultMetadataExtractor extractor = new DefaultMetadataExtractor(metadataDecoders); extractor.metadataToExtract(fooMimeType, Foo.class, "foo"); @@ -981,7 +981,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.messaging.rsocket.metadataToExtract @@ -999,7 +999,7 @@ map. Here is an example where JSON is used for metadata: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- DefaultMetadataExtractor extractor = new DefaultMetadataExtractor(metadataDecoders); extractor.metadataToExtract( @@ -1012,7 +1012,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.messaging.rsocket.metadataToExtract @@ -1031,7 +1031,7 @@ simply use a callback to customize registrations as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RSocketStrategies strategies = RSocketStrategies.builder() .metadataExtractorRegistry(registry -> { @@ -1043,7 +1043,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.messaging.rsocket.metadataToExtract @@ -1115,7 +1115,9 @@ method parameters: | `@Payload` | Set the input payload(s) for the request. This can be a concrete value, or any producer of values that can be adapted to a Reactive Streams `Publisher` via - `ReactiveAdapterRegistry` + `ReactiveAdapterRegistry`. A payload must be provided unless the `required` attribute + is set to `false`, or the parameter is marked optional as determined by + {spring-framework-api}/core/MethodParameter.html#isOptional()[`MethodParameter#isOptional`]. | `Object`, if followed by `MimeType` | The value for a metadata entry in the input payload. This can be any `Object` as long diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-junit-jupiter.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-junit-jupiter.adoc index 4944779c3b44..d359c708ea70 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-junit-jupiter.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-junit-jupiter.adoc @@ -30,7 +30,7 @@ configuration class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig.class) // <1> class ConfigurationClassJUnitJupiterSpringTests { @@ -41,7 +41,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig::class) // <1> class ConfigurationClassJUnitJupiterSpringTests { @@ -59,7 +59,7 @@ location of a configuration file: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(locations = "/test-config.xml") // <1> class XmlJUnitJupiterSpringTests { @@ -70,7 +70,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(locations = ["/test-config.xml"]) // <1> class XmlJUnitJupiterSpringTests { @@ -105,7 +105,7 @@ a configuration class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitWebConfig(TestConfig.class) // <1> class ConfigurationClassJUnitJupiterSpringWebTests { @@ -116,7 +116,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitWebConfig(TestConfig::class) // <1> class ConfigurationClassJUnitJupiterSpringWebTests { @@ -134,7 +134,7 @@ location of a configuration file: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitWebConfig(locations = "/test-config.xml") // <1> class XmlJUnitJupiterSpringWebTests { @@ -145,7 +145,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitWebConfig(locations = ["/test-config.xml"]) // <1> class XmlJUnitJupiterSpringWebTests { @@ -165,8 +165,8 @@ for further details. [[integration-testing-annotations-testconstructor]] == `@TestConstructor` -`@TestConstructor` is a type-level annotation that is used to configure how the parameters -of a test class constructor are autowired from components in the test's +`@TestConstructor` is an annotation that can be applied to a test class to configure how +the parameters of a test class constructor are autowired from components in the test's `ApplicationContext`. If `@TestConstructor` is not present or meta-present on a test class, the default _test @@ -183,25 +183,24 @@ The default _test constructor autowire mode_ can be changed by setting the default mode may be set via the xref:appendix.adoc#appendix-spring-properties[`SpringProperties`] mechanism. -As of Spring Framework 5.3, the default mode may also be configured as a +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]. If the `spring.test.constructor.autowire.mode` property is not set, test class constructors will not be automatically autowired. ===== -NOTE: As of Spring Framework 5.2, `@TestConstructor` is only supported in conjunction -with the `SpringExtension` for use with JUnit Jupiter. Note that the `SpringExtension` is -often automatically registered for you – for example, when using annotations such as -`@SpringJUnitConfig` and `@SpringJUnitWebConfig` or various test-related annotations from -Spring Boot Test. +NOTE: `@TestConstructor` is only supported in conjunction with the `SpringExtension` for +use with JUnit Jupiter. Note that the `SpringExtension` is often automatically registered +for you – for example, when using annotations such as `@SpringJUnitConfig` and +`@SpringJUnitWebConfig` or various test-related annotations from Spring Boot Test. [[integration-testing-annotations-nestedtestconfiguration]] == `@NestedTestConfiguration` -`@NestedTestConfiguration` is a type-level annotation that is used to configure how -Spring test configuration annotations are processed within enclosing class hierarchies -for inner test classes. +`@NestedTestConfiguration` is an annotation that can be applied to a test class to +configure how Spring test configuration annotations are processed within enclosing class +hierarchies for inner test classes. If `@NestedTestConfiguration` is not present or meta-present on a test class, in its supertype hierarchy, or in its enclosing class hierarchy, the default _enclosing @@ -275,7 +274,7 @@ example, you can create a custom `@EnabledOnMac` annotation as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @@ -288,7 +287,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Target(AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) @@ -341,7 +340,7 @@ example, you can create a custom `@DisabledOnMac` annotation as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @@ -354,7 +353,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Target(AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-junit4.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-junit4.adoc index 8c4c645d95af..17fdf5c9bef0 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-junit4.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-junit4.adoc @@ -13,10 +13,10 @@ xref:testing/testcontext-framework/support-classes.adoc#testcontext-junit4-runne [[integration-testing-annotations-junit4-ifprofilevalue]] == `@IfProfileValue` -`@IfProfileValue` indicates that the annotated test is enabled for a specific testing -environment. If the configured `ProfileValueSource` returns a matching `value` for the -provided `name`, the test is enabled. Otherwise, the test is disabled and, effectively, -ignored. +`@IfProfileValue` indicates that the annotated test class or test method is enabled for a +specific testing environment. If the configured `ProfileValueSource` returns a matching +`value` for the provided `name`, the test is enabled. Otherwise, the test is disabled +and, effectively, ignored. You can apply `@IfProfileValue` at the class level, the method level, or both. Class-level usage of `@IfProfileValue` takes precedence over method-level usage for any @@ -31,7 +31,7 @@ The following example shows a test that has an `@IfProfileValue` annotation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @IfProfileValue(name="java.vendor", value="Oracle Corporation") // <1> @Test @@ -43,7 +43,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @IfProfileValue(name="java.vendor", value="Oracle Corporation") // <1> @Test @@ -63,7 +63,7 @@ Consider the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @IfProfileValue(name="test-groups", values={"unit-tests", "integration-tests"}) // <1> @Test @@ -75,7 +75,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @IfProfileValue(name="test-groups", values=["unit-tests", "integration-tests"]) // <1> @Test @@ -90,17 +90,18 @@ Kotlin:: [[integration-testing-annotations-junit4-profilevaluesourceconfiguration]] == `@ProfileValueSourceConfiguration` -`@ProfileValueSourceConfiguration` is a class-level annotation that specifies what type -of `ProfileValueSource` to use when retrieving profile values configured through the -`@IfProfileValue` annotation. If `@ProfileValueSourceConfiguration` is not declared for a -test, `SystemProfileValueSource` is used by default. The following example shows how to -use `@ProfileValueSourceConfiguration`: +`@ProfileValueSourceConfiguration` is an annotation that can be applied to a test class +to specify what type of `ProfileValueSource` to use when retrieving profile values +configured through the `@IfProfileValue` annotation. If +`@ProfileValueSourceConfiguration` is not declared for a test, `SystemProfileValueSource` +is used by default. The following example shows how to use +`@ProfileValueSourceConfiguration`: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ProfileValueSourceConfiguration(CustomProfileValueSource.class) // <1> public class CustomProfileValueSourceTests { @@ -111,7 +112,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ProfileValueSourceConfiguration(CustomProfileValueSource::class) // <1> class CustomProfileValueSourceTests { @@ -137,7 +138,7 @@ example shows how to use it: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Timed(millis = 1000) // <1> public void testProcessWithOneSecondTimeout() { @@ -148,7 +149,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Timed(millis = 1000) // <1> fun testProcessWithOneSecondTimeout() { @@ -182,7 +183,7 @@ following example shows how to use the `@Repeat` annotation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Repeat(10) // <1> @Test @@ -194,7 +195,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Repeat(10) // <1> @Test diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-meta.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-meta.adoc index 78164ab07744..ce2381c7c1c5 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-meta.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-meta.adoc @@ -43,7 +43,7 @@ Consider the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RunWith(SpringRunner.class) @ContextConfiguration({"/app-config.xml", "/test-data-access-config.xml"}) @@ -60,7 +60,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RunWith(SpringRunner::class) @ContextConfiguration("/app-config.xml", "/test-data-access-config.xml") @@ -84,7 +84,7 @@ that centralizes the common test configuration for Spring, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @@ -96,7 +96,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Target(AnnotationTarget.TYPE) @Retention(AnnotationRetention.RUNTIME) @@ -114,7 +114,7 @@ configuration of individual JUnit 4 based test classes, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RunWith(SpringRunner.class) @TransactionalDevTestConfig @@ -127,7 +127,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RunWith(SpringRunner::class) @TransactionalDevTestConfig @@ -147,7 +147,7 @@ example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) @ContextConfiguration({"/app-config.xml", "/test-data-access-config.xml"}) @@ -164,7 +164,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) @ContextConfiguration("/app-config.xml", "/test-data-access-config.xml") @@ -189,7 +189,7 @@ as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @@ -202,7 +202,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Target(AnnotationTarget.TYPE) @Retention(AnnotationRetention.RUNTIME) @@ -221,7 +221,7 @@ configuration of individual JUnit Jupiter based test classes, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @TransactionalDevTestConfig class OrderRepositoryTests { } @@ -232,7 +232,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @TransactionalDevTestConfig class OrderRepositoryTests { } @@ -253,7 +253,7 @@ follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @@ -265,7 +265,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Target(AnnotationTarget.TYPE) @Retention(AnnotationRetention.RUNTIME) @@ -283,7 +283,7 @@ configuration of individual JUnit Jupiter based test methods, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @TransactionalIntegrationTest void saveOrder() { } @@ -294,7 +294,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @TransactionalIntegrationTest fun saveOrder() { } diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring.adoc index 3804efbc56f7..300c32dc8911 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring.adoc @@ -16,6 +16,8 @@ Spring's testing annotations include the following: * xref:testing/annotations/integration-spring/annotation-activeprofiles.adoc[`@ActiveProfiles`] * xref:testing/annotations/integration-spring/annotation-testpropertysource.adoc[`@TestPropertySource`] * xref:testing/annotations/integration-spring/annotation-dynamicpropertysource.adoc[`@DynamicPropertySource`] +* xref:testing/annotations/integration-spring/annotation-testbean.adoc[`@TestBean`] +* xref:testing/annotations/integration-spring/annotation-mockitobean.adoc[`@MockitoBean` and `@MockitoSpyBean`] * xref:testing/annotations/integration-spring/annotation-dirtiescontext.adoc[`@DirtiesContext`] * xref:testing/annotations/integration-spring/annotation-testexecutionlisteners.adoc[`@TestExecutionListeners`] * xref:testing/annotations/integration-spring/annotation-recordapplicationevents.adoc[`@RecordApplicationEvents`] diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-activeprofiles.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-activeprofiles.adoc index 31299b96bd07..62233f402d9b 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-activeprofiles.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-activeprofiles.adoc @@ -1,8 +1,8 @@ [[spring-testing-annotation-activeprofiles]] = `@ActiveProfiles` -`@ActiveProfiles` is a class-level annotation that is used to declare which bean -definition profiles should be active when loading an `ApplicationContext` for an +`@ActiveProfiles` is an annotation that can be applied to a test class to declare which +bean definition profiles should be active when loading an `ApplicationContext` for an integration test. The following example indicates that the `dev` profile should be active: @@ -11,7 +11,7 @@ The following example indicates that the `dev` profile should be active: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @ActiveProfiles("dev") // <1> @@ -23,7 +23,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @ActiveProfiles("dev") // <1> @@ -42,7 +42,7 @@ be active: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @ActiveProfiles({"dev", "integration"}) // <1> @@ -54,7 +54,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @ActiveProfiles(["dev", "integration"]) // <1> diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-aftertransaction.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-aftertransaction.adoc index 3a2e3e73eaad..daa7c6995cf6 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-aftertransaction.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-aftertransaction.adoc @@ -11,7 +11,7 @@ methods. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @AfterTransaction // <1> void afterTransaction() { @@ -22,7 +22,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @AfterTransaction // <1> fun afterTransaction() { diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-beforetransaction.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-beforetransaction.adoc index 6bf76783406c..dc3cc242294c 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-beforetransaction.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-beforetransaction.adoc @@ -13,7 +13,7 @@ The following example shows how to use the `@BeforeTransaction` annotation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @BeforeTransaction // <1> void beforeTransaction() { @@ -24,7 +24,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @BeforeTransaction // <1> fun beforeTransaction() { diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-bootstrapwith.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-bootstrapwith.adoc index 5769b3d3d3f3..a297b0149944 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-bootstrapwith.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-bootstrapwith.adoc @@ -2,8 +2,8 @@ = `@BootstrapWith` :page-section-summary-toc: 1 -`@BootstrapWith` is a class-level annotation that you can use to configure how the Spring -TestContext Framework is bootstrapped. Specifically, you can use `@BootstrapWith` to -specify a custom `TestContextBootstrapper`. See the section on +`@BootstrapWith` is an annotation that can be applied to a test class to configure how +the Spring TestContext Framework is bootstrapped. Specifically, you can use +`@BootstrapWith` to specify a custom `TestContextBootstrapper`. See the section on xref:testing/testcontext-framework/bootstrapping.adoc[bootstrapping the TestContext framework] for further details. diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-commit.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-commit.adoc index 46440b35ced0..555f8de110eb 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-commit.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-commit.adoc @@ -13,7 +13,7 @@ The following example shows how to use the `@Commit` annotation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Commit // <1> @Test @@ -25,7 +25,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Commit // <1> @Test diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-contextconfiguration.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-contextconfiguration.adoc index 47294f5adae4..30b7e0e2ced8 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-contextconfiguration.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-contextconfiguration.adoc @@ -1,10 +1,10 @@ [[spring-testing-annotation-contextconfiguration]] = `@ContextConfiguration` -`@ContextConfiguration` defines class-level metadata that is used to determine how to -load and configure an `ApplicationContext` for integration tests. Specifically, -`@ContextConfiguration` declares the application context resource `locations` or the -component `classes` used to load the context. +`@ContextConfiguration` is an annotation that can be applied to a test class to configure +metadata that is used to determine how to load and configure an `ApplicationContext` for +integration tests. Specifically, `@ContextConfiguration` declares the application context +resource `locations` or the component `classes` used to load the context. Resource locations are typically XML configuration files or Groovy scripts located in the classpath, while component classes are typically `@Configuration` classes. However, @@ -19,7 +19,7 @@ file: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration("/test-config.xml") // <1> class XmlApplicationContextTests { @@ -30,7 +30,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration("/test-config.xml") // <1> class XmlApplicationContextTests { @@ -47,7 +47,7 @@ The following example shows a `@ContextConfiguration` annotation that refers to ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration(classes = TestConfig.class) // <1> class ConfigClassApplicationContextTests { @@ -58,7 +58,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration(classes = [TestConfig::class]) // <1> class ConfigClassApplicationContextTests { @@ -77,7 +77,7 @@ The following example shows such a case: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration(initializers = CustomContextInitializer.class) // <1> class ContextInitializerTests { @@ -88,7 +88,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration(initializers = [CustomContextInitializer::class]) // <1> class ContextInitializerTests { @@ -110,7 +110,7 @@ The following example uses both a location and a loader: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration(locations = "/test-context.xml", loader = CustomContextLoader.class) // <1> class CustomLoaderXmlApplicationContextTests { @@ -121,7 +121,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration("/test-context.xml", loader = CustomContextLoader::class) // <1> class CustomLoaderXmlApplicationContextTests { diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-contextcustomizerfactories.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-contextcustomizerfactories.adoc index 0dc49e7bec52..5bd9ecc41ece 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-contextcustomizerfactories.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-contextcustomizerfactories.adoc @@ -1,10 +1,10 @@ [[spring-testing-annotation-contextcustomizerfactories]] = `@ContextCustomizerFactories` -`@ContextCustomizerFactories` is used to register `ContextCustomizerFactory` -implementations for a particular test class, its subclasses, and its nested classes. If -you wish to register a factory globally, you should register it via the automatic -discovery mechanism described in +`@ContextCustomizerFactories` is an annotation that can be applied to a test class to +register `ContextCustomizerFactory` implementations for the particular test class, its +subclasses, and its nested classes. If you wish to register a factory globally, you +should register it via the automatic discovery mechanism described in xref:testing/testcontext-framework/ctx-management/context-customizers.adoc[`ContextCustomizerFactory` Configuration]. The following example shows how to register two `ContextCustomizerFactory` implementations: @@ -13,7 +13,7 @@ The following example shows how to register two `ContextCustomizerFactory` imple ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @ContextCustomizerFactories({CustomContextCustomizerFactory.class, AnotherContextCustomizerFactory.class}) // <1> @@ -25,7 +25,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @ContextCustomizerFactories([CustomContextCustomizerFactory::class, AnotherContextCustomizerFactory::class]) // <1> diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-contexthierarchy.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-contexthierarchy.adoc index 66aedbcb9fd0..a031e048e8e4 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-contexthierarchy.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-contexthierarchy.adoc @@ -1,18 +1,18 @@ [[spring-testing-annotation-contexthierarchy]] = `@ContextHierarchy` -`@ContextHierarchy` is a class-level annotation that is used to define a hierarchy of -`ApplicationContext` instances for integration tests. `@ContextHierarchy` should be -declared with a list of one or more `@ContextConfiguration` instances, each of which -defines a level in the context hierarchy. The following examples demonstrate the use of -`@ContextHierarchy` within a single test class (`@ContextHierarchy` can also be used -within a test class hierarchy): +`@ContextHierarchy` is an annotation that can be applied to a test class to define a +hierarchy of `ApplicationContext` instances for integration tests. `@ContextHierarchy` +should be declared with a list of one or more `@ContextConfiguration` instances, each of +which defines a level in the context hierarchy. The following examples demonstrate the +use of `@ContextHierarchy` within a single test class (`@ContextHierarchy` can also be +used within a test class hierarchy): [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextHierarchy({ @ContextConfiguration("/parent-config.xml"), @@ -25,7 +25,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextHierarchy( ContextConfiguration("/parent-config.xml"), @@ -40,7 +40,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @WebAppConfiguration @ContextHierarchy({ @@ -54,7 +54,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @WebAppConfiguration @ContextHierarchy( diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-dirtiescontext.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-dirtiescontext.adoc index 12d361deca8a..4f7dd2399468 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-dirtiescontext.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-dirtiescontext.adoc @@ -10,9 +10,9 @@ rebuilt for any subsequent test that requires a context with the same configurat metadata. You can use `@DirtiesContext` as both a class-level and a method-level annotation within -the same class or class hierarchy. In such scenarios, the `ApplicationContext` is marked -as dirty before or after any such annotated method as well as before or after the current -test class, depending on the configured `methodMode` and `classMode`. When +the same test class or test class hierarchy. In such scenarios, the `ApplicationContext` +is marked as dirty before or after any such annotated method as well as before or after +the current test class, depending on the configured `methodMode` and `classMode`. When `@DirtiesContext` is declared at both the class level and the method level, the configured modes from both annotations will be honored. For example, if the class mode is set to `BEFORE_EACH_TEST_METHOD` and the method mode is set to `AFTER_METHOD`, the @@ -28,7 +28,7 @@ configuration scenarios: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @DirtiesContext(classMode = BEFORE_CLASS) // <1> class FreshContextTests { @@ -39,7 +39,7 @@ Java:: + Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @DirtiesContext(classMode = BEFORE_CLASS) // <1> class FreshContextTests { @@ -56,7 +56,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @DirtiesContext // <1> class ContextDirtyingTests { @@ -67,7 +67,7 @@ Java:: + Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @DirtiesContext // <1> class ContextDirtyingTests { @@ -85,7 +85,7 @@ mode set to `BEFORE_EACH_TEST_METHOD.` ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @DirtiesContext(classMode = BEFORE_EACH_TEST_METHOD) // <1> class FreshContextTests { @@ -96,7 +96,7 @@ Java:: + Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @DirtiesContext(classMode = BEFORE_EACH_TEST_METHOD) // <1> class FreshContextTests { @@ -114,7 +114,7 @@ mode set to `AFTER_EACH_TEST_METHOD.` ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @DirtiesContext(classMode = AFTER_EACH_TEST_METHOD) // <1> class ContextDirtyingTests { @@ -125,7 +125,7 @@ Java:: + Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @DirtiesContext(classMode = AFTER_EACH_TEST_METHOD) // <1> class ContextDirtyingTests { @@ -143,7 +143,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @DirtiesContext(methodMode = BEFORE_METHOD) // <1> @Test @@ -155,7 +155,7 @@ Java:: + Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @DirtiesContext(methodMode = BEFORE_METHOD) // <1> @Test @@ -173,7 +173,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @DirtiesContext // <1> @Test @@ -185,7 +185,7 @@ Java:: + Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @DirtiesContext // <1> @Test @@ -211,7 +211,7 @@ as the following example shows. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextHierarchy({ @ContextConfiguration("/parent-config.xml"), @@ -234,7 +234,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextHierarchy( ContextConfiguration("/parent-config.xml"), diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-disabledinaotmode.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-disabledinaotmode.adoc index 2fbfc6a19b33..be7689d1b269 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-disabledinaotmode.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-disabledinaotmode.adoc @@ -1,7 +1,7 @@ [[spring-testing-annotation-disabledinaotmode]] = `@DisabledInAotMode` -`@DisabledInAotMode` signals that an annotated test class is disabled in Spring AOT +`@DisabledInAotMode` signals that the annotated test class is disabled in Spring AOT (ahead-of-time) mode, which means that the `ApplicationContext` for the test class will not be processed for AOT optimizations at build time. diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-dynamicpropertysource.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-dynamicpropertysource.adoc index 4c6e37863c36..3aeccb7f8cf4 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-dynamicpropertysource.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-dynamicpropertysource.adoc @@ -1,12 +1,12 @@ [[spring-testing-annotation-dynamicpropertysource]] = `@DynamicPropertySource` -`@DynamicPropertySource` is a method-level annotation that you can use to register -_dynamic_ properties to be added to the set of `PropertySources` in the `Environment` for -an `ApplicationContext` loaded for an integration test. Dynamic properties are useful -when you do not know the value of the properties upfront – for example, if the properties -are managed by an external resource such as for a container managed by the -{testcontainers-site}[Testcontainers] project. +`@DynamicPropertySource` is an annotation that can be applied to methods in integration +test classes that need to register _dynamic_ properties to be added to the set of +`PropertySources` in the `Environment` for an `ApplicationContext` loaded for an +integration test. Dynamic properties are useful when you do not know the value of the +properties upfront – for example, if the properties are managed by an external resource +such as for a container managed by the {testcontainers-site}[Testcontainers] project. The following example demonstrates how to register a dynamic property: @@ -14,7 +14,7 @@ The following example demonstrates how to register a dynamic property: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration class MyIntegrationTests { @@ -35,7 +35,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration class MyIntegrationTests { diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc new file mode 100644 index 000000000000..153a3b03435f --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc @@ -0,0 +1,289 @@ +[[spring-testing-annotation-beanoverriding-mockitobean]] += `@MockitoBean` and `@MockitoSpyBean` + +{spring-framework-api}/test/context/bean/override/mockito/MockitoBean.html[`@MockitoBean`] and +{spring-framework-api}/test/context/bean/override/mockito/MockitoSpyBean.html[`@MockitoSpyBean`] +can be used in test classes to override a bean in the test's `ApplicationContext` with a +Mockito _mock_ or _spy_, respectively. In the latter case, an early instance of the +original bean is captured and wrapped by the spy. + +The annotations can be applied in the following ways. + +* On a non-static field in a test class or any of its superclasses. +* On a non-static field in an enclosing class for a `@Nested` test class or in any class + in the type hierarchy or enclosing class hierarchy above the `@Nested` test class. +* At the type level on a test class or any superclass or implemented interface in the + type hierarchy above the test class. +* At the type level on an enclosing class for a `@Nested` test class or on any class or + interface in the type hierarchy or enclosing class hierarchy above the `@Nested` test + class. + +When `@MockitoBean` or `@MockitoSpyBean` is declared on a field, the bean to mock or spy +is inferred from the type of the annotated field. If multiple candidates exist in the +`ApplicationContext`, a `@Qualifier` annotation can be declared on the field to help +disambiguate. In the absence of a `@Qualifier` annotation, the name of the annotated +field will be used as a _fallback qualifier_. Alternatively, you can explicitly specify a +bean name to mock or spy by setting the `value` or `name` attribute in the annotation. + +When `@MockitoBean` or `@MockitoSpyBean` is declared at the type level, the type of bean +(or beans) to mock or spy must be supplied via the `types` attribute in the annotation – +for example, `@MockitoBean(types = {OrderService.class, UserService.class})`. If multiple +candidates exist in the `ApplicationContext`, you can explicitly specify a bean name to +mock or spy by setting the `name` attribute. Note, however, that the `types` attribute +must contain a single type if an explicit bean `name` is configured – for example, +`@MockitoBean(name = "ps1", types = PrintingService.class)`. + +To support reuse of mock configuration, `@MockitoBean` and `@MockitoSpyBean` may be used +as meta-annotations to create custom _composed annotations_ – for example, to define +common mock or spy configuration in a single annotation that can be reused across a test +suite. `@MockitoBean` and `@MockitoSpyBean` can also be used as repeatable annotations at +the type level — for example, to mock or spy several beans by name. + +[WARNING] +==== +Qualifiers, including the name of a field, are used to determine if a separate +`ApplicationContext` needs to be created. If you are using this feature to mock or spy +the same bean in several test classes, make sure to name the fields consistently to avoid +creating unnecessary contexts. +==== + +Each annotation also defines Mockito-specific attributes to fine-tune the mocking behavior. + +The `@MockitoBean` annotation uses the `REPLACE_OR_CREATE` +xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-strategy[strategy for bean overrides]. +If a corresponding bean does not exist, a new bean will be created. However, you can +switch to the `REPLACE` strategy by setting the `enforceOverride` attribute to `true` – +for example, `@MockitoBean(enforceOverride = true)`. + +The `@MockitoSpyBean` annotation uses the `WRAP` +xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-strategy[strategy], +and the original instance is wrapped in a Mockito spy. This strategy requires that +exactly one candidate bean exists. + +[TIP] +==== +Only _singleton_ beans can be overridden. Any attempt to override a non-singleton bean +will result in an exception. + +When using `@MockitoBean` to mock a bean created by a `FactoryBean`, the `FactoryBean` +will be replaced with a singleton mock of the type of object created by the `FactoryBean`. + +When using `@MockitoSpyBean` to create a spy for a `FactoryBean`, a spy will be created +for the object created by the `FactoryBean`, not for the `FactoryBean` itself. +==== + +[NOTE] +==== +There are no restrictions on the visibility of `@MockitoBean` and `@MockitoSpyBean` +fields. + +Such fields can therefore be `public`, `protected`, package-private (default visibility), +or `private` depending on the needs or coding practices of the project. +==== + +[[spring-testing-annotation-beanoverriding-mockitobean-examples]] +== `@MockitoBean` Examples + +The following example shows how to use the default behavior of the `@MockitoBean` +annotation. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig.class) + class BeanOverrideTests { + + @MockitoBean // <1> + CustomService customService; + + // tests... + } +---- +<1> Replace the bean with type `CustomService` with a Mockito mock. +====== + +In the example above, we are creating a mock for `CustomService`. If more than one bean +of that type exists, the bean named `customService` is considered. Otherwise, the test +will fail, and you will need to provide a qualifier of some sort to identify which of the +`CustomService` beans you want to override. If no such bean exists, a bean will be +created with an auto-generated bean name. + +The following example uses a by-name lookup, rather than a by-type lookup. If no bean +named `service` exists, one is created. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig.class) + class BeanOverrideTests { + + @MockitoBean("service") // <1> + CustomService customService; + + // tests... + + } +---- +<1> Replace the bean named `service` with a Mockito mock. +====== + +The following `@SharedMocks` annotation registers two mocks by-type and one mock by-name. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.RUNTIME) + @MockitoBean(types = {OrderService.class, UserService.class}) // <1> + @MockitoBean(name = "ps1", types = PrintingService.class) // <2> + public @interface SharedMocks { + } +---- +<1> Register `OrderService` and `UserService` mocks by-type. +<2> Register `PrintingService` mock by-name. +====== + +The following demonstrates how `@SharedMocks` can be used on a test class. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig.class) + @SharedMocks // <1> + class BeanOverrideTests { + + @Autowired OrderService orderService; // <2> + + @Autowired UserService userService; // <2> + + @Autowired PrintingService ps1; // <2> + + // Inject other components that rely on the mocks. + + @Test + void testThatDependsOnMocks() { + // ... + } + } +---- +<1> Register common mocks via the custom `@SharedMocks` annotation. +<2> Optionally inject mocks to _stub_ or _verify_ them. +====== + +TIP: The mocks can also be injected into `@Configuration` classes or other test-related +components in the `ApplicationContext` in order to configure them with Mockito's stubbing +APIs. + +[[spring-testing-annotation-beanoverriding-mockitospybean-examples]] +== `@MockitoSpyBean` Examples + +The following example shows how to use the default behavior of the `@MockitoSpyBean` +annotation. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig.class) + class BeanOverrideTests { + + @MockitoSpyBean // <1> + CustomService customService; + + // tests... + } +---- +<1> Wrap the bean with type `CustomService` with a Mockito spy. +====== + +In the example above, we are wrapping the bean with type `CustomService`. If more than +one bean of that type exists, the bean named `customService` is considered. Otherwise, +the test will fail, and you will need to provide a qualifier of some sort to identify +which of the `CustomService` beans you want to spy. + +The following example uses a by-name lookup, rather than a by-type lookup. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig.class) + class BeanOverrideTests { + + @MockitoSpyBean("service") // <1> + CustomService customService; + + // tests... + } +---- +<1> Wrap the bean named `service` with a Mockito spy. +====== + +The following `@SharedSpies` annotation registers two spies by-type and one spy by-name. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.RUNTIME) + @MockitoSpyBean(types = {OrderService.class, UserService.class}) // <1> + @MockitoSpyBean(name = "ps1", types = PrintingService.class) // <2> + public @interface SharedSpies { + } +---- +<1> Register `OrderService` and `UserService` spies by-type. +<2> Register `PrintingService` spy by-name. +====== + +The following demonstrates how `@SharedSpies` can be used on a test class. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig.class) + @SharedSpies // <1> + class BeanOverrideTests { + + @Autowired OrderService orderService; // <2> + + @Autowired UserService userService; // <2> + + @Autowired PrintingService ps1; // <2> + + // Inject other components that rely on the spies. + + @Test + void testThatDependsOnMocks() { + // ... + } + } +---- +<1> Register common spies via the custom `@SharedSpies` annotation. +<2> Optionally inject spies to _stub_ or _verify_ them. +====== + +TIP: The spies can also be injected into `@Configuration` classes or other test-related +components in the `ApplicationContext` in order to configure them with Mockito's stubbing +APIs. diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-recordapplicationevents.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-recordapplicationevents.adoc index 73365f75e1bd..15f41b4192bb 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-recordapplicationevents.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-recordapplicationevents.adoc @@ -2,9 +2,9 @@ = `@RecordApplicationEvents` :page-section-summary-toc: 1 -`@RecordApplicationEvents` is a class-level annotation that is used to instruct the -_Spring TestContext Framework_ to record all application events that are published in the -`ApplicationContext` during the execution of a single test. +`@RecordApplicationEvents` is an annotation that can be applied to a test class to +instruct the _Spring TestContext Framework_ to record all application events that are +published in the `ApplicationContext` during the execution of a single test. The recorded events can be accessed via the `ApplicationEvents` API within tests. diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-rollback.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-rollback.adoc index 93f2e32c6b67..9edd23885459 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-rollback.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-rollback.adoc @@ -19,7 +19,7 @@ result is committed to the database): ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Rollback(false) // <1> @Test @@ -31,7 +31,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Rollback(false) // <1> @Test diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sql.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sql.adoc index f84aa6b95017..09289d3f56db 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sql.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sql.adoc @@ -9,7 +9,7 @@ it: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Test @Sql({"/test-schema.sql", "/test-user-data.sql"}) // <1> @@ -21,7 +21,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Test @Sql("/test-schema.sql", "/test-user-data.sql") // <1> diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sqlconfig.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sqlconfig.adoc index 9910dd7b4555..a06c77fbf781 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sqlconfig.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sqlconfig.adoc @@ -8,7 +8,7 @@ configured with the `@Sql` annotation. The following example shows how to use it ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Test @Sql( @@ -23,7 +23,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Test @Sql("/test-user-data.sql", config = SqlConfig(commentPrefix = "`", separator = "@@")) // <1> diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sqlgroup.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sqlgroup.adoc index 104e03a1a3e2..fde8964b3290 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sqlgroup.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sqlgroup.adoc @@ -11,7 +11,7 @@ annotation. The following example shows how to declare an SQL group: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Test @SqlGroup({ // <1> @@ -26,7 +26,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Test @SqlGroup( // <1> diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sqlmergemode.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sqlmergemode.adoc index afb3b91dc220..7cb26ee729dd 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sqlmergemode.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-sqlmergemode.adoc @@ -15,7 +15,7 @@ The following example shows how to use `@SqlMergeMode` at the class level. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig.class) @Sql("/test-schema.sql") @@ -33,7 +33,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig::class) @Sql("/test-schema.sql") @@ -56,7 +56,7 @@ The following example shows how to use `@SqlMergeMode` at the method level. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig.class) @Sql("/test-schema.sql") @@ -74,7 +74,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig::class) @Sql("/test-schema.sql") diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc new file mode 100644 index 000000000000..a9cc9ced52dc --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc @@ -0,0 +1,114 @@ +[[spring-testing-annotation-beanoverriding-testbean]] += `@TestBean` + +{spring-framework-api}/test/context/bean/override/convention/TestBean.html[`@TestBean`] +is used on a non-static field in a test class to override a specific bean in the test's +`ApplicationContext` with an instance provided by a factory method. + +The associated factory method name is derived from the annotated field's name, or the +bean name if specified. The factory method must be `static`, accept no arguments, and +have a return type compatible with the type of the bean to override. To make things more +explicit, or if you'd rather use a different name, the annotation allows for a specific +method name to be provided. + +By default, the annotated field's type is used to search for candidate beans to override. +If multiple candidates match, `@Qualifier` can be provided to narrow the candidate to +override. Alternatively, a candidate whose bean name matches the name of the field will +match. + +A bean will be created if a corresponding bean does not exist. However, if you would like +for the test to fail when a corresponding bean does not exist, you can set the +`enforceOverride` attribute to `true` – for example, `@TestBean(enforceOverride = true)`. + +To use a by-name override rather than a by-type override, specify the `name` attribute +of the annotation. + +[WARNING] +==== +Qualifiers, including the name of the field, are used to determine if a separate +`ApplicationContext` needs to be created. If you are using this feature to override the +same bean in several tests, make sure to name the field consistently to avoid creating +unnecessary contexts. +==== + +[NOTE] +==== +There are no restrictions on the visibility of `@TestBean` fields or factory methods. + +Such fields and methods can therefore be `public`, `protected`, package-private (default +visibility), or `private` depending on the needs or coding practices of the project. +==== + +The following example shows how to use the default behavior of the `@TestBean` annotation: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + class OverrideBeanTests { + @TestBean // <1> + CustomService customService; + + // test case body... + + static CustomService customService() { // <2> + return new MyFakeCustomService(); + } + } +---- +<1> Mark a field for overriding the bean with type `CustomService`. +<2> The result of this static method will be used as the instance and injected into the field. +====== + +In the example above, we are overriding the bean with type `CustomService`. If more than +one bean of that type exists, the bean named `customService` is considered. Otherwise, +the test will fail, and you will need to provide a qualifier of some sort to identify +which of the `CustomService` beans you want to override. + +The following example uses a by-name lookup, rather than a by-type lookup: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + class OverrideBeanTests { + @TestBean(name = "service", methodName = "createCustomService") // <1> + CustomService customService; + + // test case body... + + static CustomService createCustomService() { // <2> + return new MyFakeCustomService(); + } + } +---- +<1> Mark a field for overriding the bean with name `service`, and specify that the + factory method is named `createCustomService`. +<2> The result of this static method will be used as the instance and injected into the field. +====== + +[TIP] +==== +To locate the factory method to invoke, Spring searches in the class in which the +`@TestBean` field is declared, in one of its superclasses, or in any implemented +interfaces. If the `@TestBean` field is declared in a `@Nested` test class, the enclosing +class hierarchy will also be searched. + +Alternatively, a factory method in an external class can be referenced via its +fully-qualified method name following the syntax `#` +– for example, `methodName = "org.example.TestUtils#createCustomService"`. +==== + +[TIP] +==== +Only _singleton_ beans can be overridden. Any attempt to override a non-singleton bean +will result in an exception. + +When overriding a bean created by a `FactoryBean`, the `FactoryBean` will be replaced +with a singleton bean corresponding to the value returned from the `@TestBean` factory +method. +==== diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testexecutionlisteners.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testexecutionlisteners.adoc index 370d1e7d5865..aada318fdfa8 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testexecutionlisteners.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testexecutionlisteners.adoc @@ -1,7 +1,7 @@ [[spring-testing-annotation-testexecutionlisteners]] = `@TestExecutionListeners` -`@TestExecutionListeners` is used to register listeners for a particular test class, its +`@TestExecutionListeners` is used to register listeners for the annotated test class, its subclasses, and its nested classes. If you wish to register a listener globally, you should register it via the automatic discovery mechanism described in xref:testing/testcontext-framework/tel-config.adoc[`TestExecutionListener` Configuration]. @@ -12,7 +12,7 @@ The following example shows how to register two `TestExecutionListener` implemen ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestExecutionListeners({CustomTestExecutionListener.class, AnotherTestExecutionListener.class}) // <1> @@ -24,7 +24,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestExecutionListeners(CustomTestExecutionListener::class, AnotherTestExecutionListener::class) // <1> diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testpropertysource.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testpropertysource.adoc index 157fc1289024..6971ec2209fb 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testpropertysource.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testpropertysource.adoc @@ -1,8 +1,8 @@ [[spring-testing-annotation-testpropertysource]] = `@TestPropertySource` -`@TestPropertySource` is a class-level annotation that you can use to configure the -locations of properties files and inlined properties to be added to the set of +`@TestPropertySource` is an annotation that can be applied to a test class to configure +the locations of properties files and inlined properties to be added to the set of `PropertySources` in the `Environment` for an `ApplicationContext` loaded for an integration test. @@ -12,7 +12,7 @@ The following example demonstrates how to declare a properties file from the cla ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestPropertySource("/test.properties") // <1> @@ -24,7 +24,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestPropertySource("/test.properties") // <1> @@ -42,7 +42,7 @@ The following example demonstrates how to declare inlined properties: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestPropertySource(properties = { "timezone = GMT", "port: 4242" }) // <1> @@ -54,7 +54,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestPropertySource(properties = ["timezone = GMT", "port: 4242"]) // <1> diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-webappconfiguration.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-webappconfiguration.adoc index 2c3d1ac328dc..9256db492a05 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-webappconfiguration.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-webappconfiguration.adoc @@ -1,10 +1,10 @@ [[spring-testing-annotation-webappconfiguration]] = `@WebAppConfiguration` -`@WebAppConfiguration` is a class-level annotation that you can use to declare that the -`ApplicationContext` loaded for an integration test should be a `WebApplicationContext`. -The mere presence of `@WebAppConfiguration` on a test class ensures that a -`WebApplicationContext` is loaded for the test, using the default value of +`@WebAppConfiguration` is an annotation that can be applied to a test class to declare +that the `ApplicationContext` loaded for an integration test should be a +`WebApplicationContext`. The mere presence of `@WebAppConfiguration` on a test class +ensures that a `WebApplicationContext` is loaded for the test, using the default value of `"file:src/main/webapp"` for the path to the root of the web application (that is, the resource base path). The resource base path is used behind the scenes to create a `MockServletContext`, which serves as the `ServletContext` for the test's @@ -17,7 +17,7 @@ The following example shows how to use the `@WebAppConfiguration` annotation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @WebAppConfiguration // <1> @@ -29,7 +29,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @WebAppConfiguration // <1> @@ -52,7 +52,7 @@ resource. The following example shows how to specify a classpath resource: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @WebAppConfiguration("classpath:test-web-resources") // <1> @@ -64,7 +64,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @WebAppConfiguration("classpath:test-web-resources") // <1> diff --git a/framework-docs/modules/ROOT/pages/testing/integration.adoc b/framework-docs/modules/ROOT/pages/testing/integration.adoc index a7a4b39729ea..79c2a8321153 100644 --- a/framework-docs/modules/ROOT/pages/testing/integration.adoc +++ b/framework-docs/modules/ROOT/pages/testing/integration.adoc @@ -30,7 +30,7 @@ integration support, and the rest of this chapter then focuses on dedicated topi * xref:testing/support-jdbc.adoc[JDBC Testing Support] * xref:testing/testcontext-framework.adoc[Spring TestContext Framework] * xref:testing/webtestclient.adoc[WebTestClient] -* xref:testing/spring-mvc-test-framework.adoc[MockMvc] +* xref:testing/mockmvc.adoc[MockMvc] * xref:testing/spring-mvc-test-client.adoc[Testing Client Applications] * xref:testing/annotations.adoc[Annotations] diff --git a/framework-docs/modules/ROOT/pages/testing/mockmvc.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc.adoc new file mode 100644 index 000000000000..ac42f4be5e53 --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc.adoc @@ -0,0 +1,16 @@ +[[mockmvc]] += MockMvc +:page-section-summary-toc: 1 + +MockMvc provides support for testing Spring MVC applications. It performs full Spring MVC +request handling but via mock request and response objects instead of a running server. + +MockMvc can be used on its own to perform requests and verify responses using Hamcrest or +through `MockMvcTester` which provides a fluent API using AssertJ. It can also be used +through the xref:testing/webtestclient.adoc[WebTestClient] where MockMvc is plugged in as +the server to handle requests. The advantage of using `WebTestClient` is that it provides +you the option of working with higher level objects instead of raw data as well as the +ability to switch to full, end-to-end HTTP tests against a live server and use the same +test API. + + diff --git a/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj.adoc new file mode 100644 index 000000000000..aa105c91b2a3 --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj.adoc @@ -0,0 +1,16 @@ +[[mockmvc-tester]] += AssertJ Integration +:page-section-summary-toc: 1 + +The AssertJ integration builds on top of plain `MockMvc` with several differences: + +* There is no need to use static imports as both the requests and assertions can be +crafted using a fluent API. +* Unresolved exceptions are handled consistently so that your tests do not need to +throw (or catch) `Exception`. +* By default, the result to assert is complete whether the processing is asynchronous +or not. In other words, there is no need for special handling for Async requests. + +`MockMvcTester` is the entry point for the AssertJ support. It allows to craft the +request and return a result that is AssertJ compatible so that it can be wrapped in +a standard `assertThat()` method. \ No newline at end of file diff --git a/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj/assertions.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj/assertions.adoc new file mode 100644 index 000000000000..bb3a9ca2bc41 --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj/assertions.adoc @@ -0,0 +1,49 @@ +[[mockmvc-tester-assertions]] += Defining Expectations + +Assertions work the same way as any AssertJ assertions. The support provides dedicated +assert objects for the various pieces of the `MvcTestResult`, as shown in the following +example: + +include-code::./HotelControllerTests[tag=get,indent=0] + +If a request fails, the exchange does not throw the exception. Rather, you can assert +that the result of the exchange has failed: + +include-code::./HotelControllerTests[tag=failure,indent=0] + +The request could also fail unexpectedly, that is the exception thrown by the handler +has not been handled and is thrown as is. You can still use `.hasFailed()` and +`.failure()` but any attempt to access part of the result will throw an exception as +the exchange hasn't completed. + +[[mockmvc-tester-assertions-json]] +== JSON Support + +The AssertJ support for `MvcTestResult` provides JSON support via `bodyJson()`. + +If https://github.com/jayway/JsonPath[JSONPath] is available, you can apply an expression +on the JSON document. The returned value provides convenient methods to return a dedicated +assert object for the various supported JSON data types: + +include-code::./FamilyControllerTests[tag=extract-asmap,indent=0] + +You can also convert the raw content to any of your data types as long as the message +converter is configured properly: + +include-code::./FamilyControllerTests[tag=extract-convert,indent=0] + +Converting to a target `Class` provides a generic assert object. For more complex types, +you may want to use `AssertFactory` instead that returns a dedicated assert type, if +possible: + +include-code::./FamilyControllerTests[tag=extract-convert-assert-factory,indent=0] + +https://jsonassert.skyscreamer.org[JSONAssert] is also supported. The body of the +response can be matched against a `Resource` or a content. If the content ends with +`.json ` we look for a file matching that name on the classpath: + +include-code::./FamilyControllerTests[tag=assert-file,indent=0] + +If you prefer to use another library, you can provide an implementation of +{spring-framework-api}/test/json/JsonComparator.html[`JsonComparator`]. diff --git a/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj/integration.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj/integration.adoc new file mode 100644 index 000000000000..25a77205d9a1 --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj/integration.adoc @@ -0,0 +1,23 @@ +[[mockmvc-tester-integration]] += MockMvc integration + +If you want to use the AssertJ support but have invested in the original `MockMvc` +API, `MockMvcTester` offers several ways to integrate with it. + +If you have your own `RequestBuilder` implementation, you can trigger the processing +of the request using `perform`. The example below showcases how the query can be +crafted with the original API: + +include-code::./HotelControllerTests[tag=perform,indent=0] + +Similarly, if you have crafted custom matchers that you use with the `.andExpect` feature +of `MockMvc` you can use them via `.matches`. In the example below, we rewrite the +preceding example to assert the status with the `ResultMatcher` implementation that +`MockMvc` provides: + +include-code::./HotelControllerTests[tag=matches,indent=0] + +`MockMvc` also defines a `ResultHandler` contract that lets you execute arbitrary actions +on `MvcResult`. If you have implemented this contract you can invoke it using `.apply`. + + diff --git a/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj/requests.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj/requests.adoc new file mode 100644 index 000000000000..9889532fc1d2 --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj/requests.adoc @@ -0,0 +1,83 @@ +[[mockmvc-tester-requests]] += Performing Requests + +This section shows how to use `MockMvcTester` to perform requests and its integration +with AssertJ to verify responses. + +`MockMvcTester` provides a fluent API to compose the request that reuses the same +`MockHttpServletRequestBuilder` as the Hamcrest support, except that there is no need +to import a static method. The builder that is returned is AssertJ-aware so that +wrapping it in the regular `assertThat()` factory method triggers the exchange and +provides access to a dedicated Assert object for `MvcTestResult`. + +Here is a simple example that performs a `POST` on `/hotels/42` and configures the +request to specify an `Accept` header: + +include-code::./HotelControllerTests[tag=post,indent=0] + +AssertJ often consists of multiple `assertThat()` statements to validate the different +parts of the exchange. Rather than having a single statement as in the case above, you +can use `.exchange()` to return a `MvcTestResult` that can be used in multiple +`assertThat` statements: + +include-code::./HotelControllerTests[tag=post-exchange,indent=0] + +You can specify query parameters in URI template style, as the following example shows: + +include-code::./HotelControllerTests[tag=query-parameters,indent=0] + +You can also add Servlet request parameters that represent either query or form +parameters, as the following example shows: + +include-code::./HotelControllerTests[tag=parameters,indent=0] + +If application code relies on Servlet request parameters and does not check the query +string explicitly (as is most often the case), it does not matter which option you use. +Keep in mind, however, that query parameters provided with the URI template are decoded +while request parameters provided through the `param(...)` method are expected to already +be decoded. + + +[[mockmvc-tester-requests-async]] +== Async + +If the processing of the request is done asynchronously, `exchange()` waits for +the completion of the request so that the result to assert is effectively immutable. +The default timeout is 10 seconds but it can be controlled on a request-by-request +basis as shown in the following example: + +include-code::./AsyncControllerTests[tag=duration,indent=0] + +If you prefer to get the raw result and manage the lifecycle of the asynchronous +request yourself, use `asyncExchange` rather than `exchange`. + +[[mockmvc-tester-requests-multipart]] +== Multipart + +You can perform file upload requests that internally use +`MockMultipartHttpServletRequest` so that there is no actual parsing of a multipart +request. Rather, you have to set it up to be similar to the following example: + +include-code::./MultipartControllerTests[tag=snippet,indent=0] + +[[mockmvc-tester-requests-paths]] +== Using Servlet and Context Paths + +In most cases, it is preferable to leave the context path and the Servlet path out of the +request URI. If you must test with the full request URI, be sure to set the `contextPath` +and `servletPath` accordingly so that request mappings work, as the following example +shows: + +include-code::./HotelControllerTests[tag=context-servlet-paths,indent=0] + +In the preceding example, it would be cumbersome to set the `contextPath` and +`servletPath` with every performed request. Instead, you can set up default request +properties, as the following example shows: + +include-code::./HotelControllerTests[tag=default-customizations,indent=0] + +The preceding properties affect every request performed through the `mockMvc` instance. +If the same property is also specified on a given request, it overrides the default +value. That is why the HTTP method and URI in the default request do not matter, since +they must be specified on every request. + diff --git a/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj/setup.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj/setup.adoc new file mode 100644 index 000000000000..5f0317c0411c --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/assertj/setup.adoc @@ -0,0 +1,30 @@ +[[mockmvc-tester-setup]] += Configuring MockMvcTester + +`MockMvcTester` can be setup in one of two ways. One is to point directly to the +controllers you want to test and programmatically configure Spring MVC infrastructure. +The second is to point to Spring configuration with Spring MVC and controller +infrastructure in it. + +TIP: For a comparison of those two modes, check xref:testing/mockmvc/setup-options.adoc[Setup Options]. + +To set up `MockMvcTester` for testing a specific controller, use the following: + +include-code::./AccountControllerStandaloneTests[tag=snippet,indent=0] + +To set up `MockMvcTester` through Spring configuration, use the following: + +include-code::./AccountControllerIntegrationTests[tag=snippet,indent=0] + +`MockMvcTester` can convert the JSON response body, or the result of a JSONPath expression, +to one of your domain object as long as the relevant `HttpMessageConverter` is registered. + +If you use Jackson to serialize content to JSON, the following example registers the +converter: + +include-code::./converter/AccountControllerIntegrationTests[tag=snippet,indent=0] + +NOTE: The above assumes the converter has been registered as a Bean. + +Finally, if you have a `MockMvc` instance handy, you can create a `MockMvcTester` by +providing the `MockMvc` instance to use using the `create` factory method. diff --git a/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest.adoc new file mode 100644 index 000000000000..46a1ecfa4e18 --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest.adoc @@ -0,0 +1,7 @@ +[[mockmvc-server]] += Hamcrest Integration +:page-section-summary-toc: 1 + +Plain `MockMvc` provides an API to build the request using a builder-style approach +that can be initiated with static imports. Hamcrest is used to define expectations and +it provides many out-of-the-box options for common needs. \ No newline at end of file diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/async-requests.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/async-requests.adoc similarity index 93% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/async-requests.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/async-requests.adoc index 9dacf436fd5f..949b9ab8a9bd 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/async-requests.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/async-requests.adoc @@ -1,4 +1,4 @@ -[[spring-mvc-test-async-requests]] +[[mockmvc-async-requests]] = Async Requests This section shows how to use MockMvc on its own to test asynchronous request handling. @@ -20,7 +20,7 @@ or reactive type such as Reactor `Mono`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // static import of MockMvcRequestBuilders.* and MockMvcResultMatchers.* @@ -45,7 +45,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Test fun test() { diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-defining-expectations.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/expectations.adoc similarity index 85% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-defining-expectations.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/expectations.adoc index 27dbf6ed50f5..a7cb4d3b3688 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-defining-expectations.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/expectations.adoc @@ -1,4 +1,4 @@ -[[spring-mvc-test-server-defining-expectations]] +[[mockmvc-server-defining-expectations]] = Defining Expectations You can define expectations by appending one or more `andExpect(..)` calls after @@ -9,7 +9,7 @@ no other expectations will be asserted. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // static import of MockMvcRequestBuilders.* and MockMvcResultMatchers.* @@ -18,7 +18,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.test.web.servlet.get @@ -37,7 +37,7 @@ all failures will be tracked and reported. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // static import of MockMvcRequestBuilders.* and MockMvcResultMatchers.* @@ -48,7 +48,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.test.web.servlet.get @@ -78,7 +78,7 @@ The following test asserts that binding or validation failed: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- mockMvc.perform(post("/persons")) .andExpect(status().isOk()) @@ -87,7 +87,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.test.web.servlet.post @@ -108,7 +108,7 @@ request. You can do so as follows, where `print()` is a static import from ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- mockMvc.perform(post("/persons")) .andDo(print()) @@ -118,7 +118,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.test.web.servlet.post @@ -150,7 +150,7 @@ other expectations, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- MvcResult mvcResult = mockMvc.perform(post("/persons")).andExpect(status().isOk()).andReturn(); // ... @@ -158,7 +158,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- var mvcResult = mockMvc.post("/persons").andExpect { status { isOk() } }.andReturn() // ... @@ -172,7 +172,7 @@ building the `MockMvc` instance, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- standaloneSetup(new SimpleController()) .alwaysExpect(status().isOk()) @@ -182,7 +182,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Not possible in Kotlin until {kotlin-issues}/KT-22208 is fixed ---- @@ -199,7 +199,7 @@ resulting links by using JsonPath expressions, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- mockMvc.perform(get("/people").accept(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.links[?(@.rel == 'self')].href").value("http://localhost:8080/people")); @@ -207,7 +207,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- mockMvc.get("/people") { accept(MediaType.APPLICATION_JSON) @@ -227,7 +227,7 @@ resulting links by using XPath expressions: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Map ns = Collections.singletonMap("ns", "http://www.w3.org/2005/Atom"); mockMvc.perform(get("/handle").accept(MediaType.APPLICATION_XML)) @@ -236,7 +236,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val ns = mapOf("ns" to "http://www.w3.org/2005/Atom") mockMvc.get("/handle") { diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-filters.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/filters.adoc similarity index 77% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-filters.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/filters.adoc index b292ec168a3e..5060f27c3d6e 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-filters.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/filters.adoc @@ -1,4 +1,4 @@ -[[spring-mvc-test-server-filters]] +[[mockmvc-server-filters]] = Filter Registrations :page-section-summary-toc: 1 @@ -9,14 +9,14 @@ instances, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- mockMvc = standaloneSetup(new PersonController()).addFilters(new CharacterEncodingFilter()).build(); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Not possible in Kotlin until {kotlin-issues}/KT-22208 is fixed ---- diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-performing-requests.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/requests.adoc similarity index 81% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-performing-requests.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/requests.adoc index 0d6bcab7ca33..a7b565abddda 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-performing-requests.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/requests.adoc @@ -1,4 +1,4 @@ -[[spring-mvc-test-server-performing-requests]] +[[mockmvc-server-performing-requests]] = Performing Requests This section shows how to use MockMvc on its own to perform requests and verify responses. @@ -11,7 +11,7 @@ To perform requests that use any HTTP method, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // static import of MockMvcRequestBuilders.* @@ -20,7 +20,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.test.web.servlet.post @@ -38,14 +38,14 @@ request. Rather, you have to set it up to be similar to the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- mockMvc.perform(multipart("/doc").file("a1", "ABC".getBytes("UTF-8"))); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.test.web.servlet.multipart @@ -61,14 +61,14 @@ You can specify query parameters in URI template style, as the following example ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- mockMvc.perform(get("/hotels?thing={thing}", "somewhere")); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- mockMvc.get("/hotels?thing={thing}", "somewhere") ---- @@ -81,14 +81,14 @@ parameters, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- mockMvc.perform(get("/hotels").param("thing", "somewhere")); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.test.web.servlet.get @@ -113,14 +113,14 @@ shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- mockMvc.perform(get("/app/main/hotels/{id}").contextPath("/app").servletPath("/main")) ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.test.web.servlet.get @@ -139,7 +139,7 @@ properties, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- class MyWebTests { @@ -157,7 +157,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Not possible in Kotlin until {kotlin-issues}/KT-22208 is fixed ---- diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-setup-steps.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/setup-steps.adoc similarity index 84% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-setup-steps.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/setup-steps.adoc index e179a8364a27..ab426c43f332 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-setup-steps.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/setup-steps.adoc @@ -1,4 +1,4 @@ -[[spring-mvc-test-server-setup-steps]] +[[mockmvc-server-setup-steps]] = Setup Features No matter which MockMvc builder you use, all `MockMvcBuilder` implementations provide @@ -10,7 +10,7 @@ responses, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // static import of MockMvcBuilders.standaloneSetup @@ -23,7 +23,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Not possible in Kotlin until {kotlin-issues}/KT-22208 is fixed ---- @@ -38,7 +38,7 @@ You can use it as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // static import of SharedHttpSessionConfigurer.sharedHttpSession @@ -51,7 +51,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Not possible in Kotlin until {kotlin-issues}/KT-22208 is fixed ---- diff --git a/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/setup.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/setup.adoc new file mode 100644 index 000000000000..1e87ae7c9150 --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/setup.adoc @@ -0,0 +1,100 @@ +[[mockmvc-setup]] += Configuring MockMvc + +MockMvc can be setup in one of two ways. One is to point directly to the controllers you +want to test and programmatically configure Spring MVC infrastructure. The second is to +point to Spring configuration with Spring MVC and controller infrastructure in it. + +TIP: For a comparison of those two modes, check xref:testing/mockmvc/setup-options.adoc[Setup Options]. + +To set up MockMvc for testing a specific controller, use the following: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + class MyWebTests { + + MockMvc mockMvc; + + @BeforeEach + void setup() { + this.mockMvc = MockMvcBuilders.standaloneSetup(new AccountController()).build(); + } + + // ... + + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + class MyWebTests { + + lateinit var mockMvc : MockMvc + + @BeforeEach + fun setup() { + mockMvc = MockMvcBuilders.standaloneSetup(AccountController()).build() + } + + // ... + + } +---- +====== + +Or you can also use this setup when testing through the +xref:testing/webtestclient.adoc#webtestclient-controller-config[WebTestClient] which delegates to the same builder +as shown above. + +To set up MockMvc through Spring configuration, use the following: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitWebConfig(locations = "my-servlet-context.xml") + class MyWebTests { + + MockMvc mockMvc; + + @BeforeEach + void setup(WebApplicationContext wac) { + this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build(); + } + + // ... + + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitWebConfig(locations = ["my-servlet-context.xml"]) + class MyWebTests { + + lateinit var mockMvc: MockMvc + + @BeforeEach + fun setup(wac: WebApplicationContext) { + mockMvc = MockMvcBuilders.webAppContextSetup(wac).build() + } + + // ... + + } +---- +====== + +Or you can also use this setup when testing through the +xref:testing/webtestclient.adoc#webtestclient-context-config[WebTestClient] which delegates to the same builder +as shown above. diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-static-imports.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/static-imports.adoc similarity index 93% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-static-imports.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/static-imports.adoc index 21ccea19311e..652eef58b31b 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-static-imports.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/static-imports.adoc @@ -1,4 +1,4 @@ -[[spring-mvc-test-server-static-imports]] +[[mockmvc-server-static-imports]] = Static Imports :page-section-summary-toc: 1 diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/vs-streaming-response.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/vs-streaming-response.adoc similarity index 78% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/vs-streaming-response.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/vs-streaming-response.adoc index 6a24b6ca7c45..18813c7c529e 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/vs-streaming-response.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/hamcrest/vs-streaming-response.adoc @@ -1,4 +1,4 @@ -[[spring-mvc-test-vs-streaming-response]] +[[mockmvc-vs-streaming-response]] = Streaming Responses You can use `WebTestClient` to test xref:testing/webtestclient.adoc#webtestclient-stream[streaming responses] @@ -7,7 +7,7 @@ streams because there is no way to cancel the server stream from the client side To test infinite streams, you'll need to xref:testing/webtestclient.adoc#webtestclient-server-config[bind to] a running server, or when using Spring Boot, -{spring-boot-docs}/spring-boot-features.html#boot-features-testing-spring-boot-applications-testing-with-running-server[test with a running server]. +{spring-boot-docs-ref}/testing/spring-boot-applications.html#testing.spring-boot-applications.with-running-server[test with a running server]. `MockMvcWebTestClient` does support asynchronous responses, and even streaming responses. The limitation is that it can't influence the server to stop, and therefore the server diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit.adoc similarity index 86% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit.adoc index 03895dfa44e7..52cbacc3e797 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit.adoc @@ -1,8 +1,8 @@ -[[spring-mvc-test-server-htmlunit]] +[[mockmvc-server-htmlunit]] = HtmlUnit Integration :page-section-summary-toc: 1 -Spring provides integration between xref:testing/spring-mvc-test-framework/server.adoc[MockMvc] and +Spring provides integration between xref:testing/mockmvc/overview.adoc[MockMvc] and https://htmlunit.sourceforge.io/[HtmlUnit]. This simplifies performing end-to-end testing when using HTML-based views. This integration lets you: diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit/geb.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/geb.adoc similarity index 84% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit/geb.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/geb.adoc index 8be8dd529290..ad4ece55138b 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit/geb.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/geb.adoc @@ -1,18 +1,18 @@ -[[spring-mvc-test-server-htmlunit-geb]] +[[mockmvc-server-htmlunit-geb]] = MockMvc and Geb In the previous section, we saw how to use MockMvc with WebDriver. In this section, we use https://www.gebish.org/[Geb] to make our tests even Groovy-er. -[[spring-mvc-test-server-htmlunit-geb-why]] +[[mockmvc-server-htmlunit-geb-why]] == Why Geb and MockMvc? Geb is backed by WebDriver, so it offers many of the -xref:testing/spring-mvc-test-framework/server-htmlunit/webdriver.adoc#spring-mvc-test-server-htmlunit-webdriver-why[same benefits] that we get from +xref:testing/mockmvc/htmlunit/webdriver.adoc#spring-mvc-test-server-htmlunit-webdriver-why[same benefits] that we get from WebDriver. However, Geb makes things even easier by taking care of some of the boilerplate code for us. -[[spring-mvc-test-server-htmlunit-geb-setup]] +[[mockmvc-server-htmlunit-geb-setup]] == MockMvc and Geb Setup We can easily initialize a Geb `Browser` with a Selenium WebDriver that uses MockMvc, as @@ -28,14 +28,14 @@ def setup() { ---- NOTE: This is a simple example of using `MockMvcHtmlUnitDriverBuilder`. For more advanced -usage, see xref:testing/spring-mvc-test-framework/server-htmlunit/webdriver.adoc#spring-mvc-test-server-htmlunit-webdriver-advanced-builder[Advanced `MockMvcHtmlUnitDriverBuilder`]. +usage, see xref:testing/mockmvc/htmlunit/webdriver.adoc#spring-mvc-test-server-htmlunit-webdriver-advanced-builder[Advanced `MockMvcHtmlUnitDriverBuilder`]. This ensures that any URL referencing `localhost` as the server is directed to our `MockMvc` instance without the need for a real HTTP connection. Any other URL is requested by using a network connection as normal. This lets us easily test the use of CDNs. -[[spring-mvc-test-server-htmlunit-geb-usage]] +[[mockmvc-server-htmlunit-geb-usage]] == MockMvc and Geb Usage Now we can use Geb as we normally would but without the need to deploy our application to @@ -62,7 +62,7 @@ forwarded to the current page object. This removes a lot of the boilerplate code needed when using WebDriver directly. As with direct WebDriver usage, this improves on the design of our -xref:testing/spring-mvc-test-framework/server-htmlunit/mah.adoc#spring-mvc-test-server-htmlunit-mah-usage[HtmlUnit test] by using the Page Object +xref:testing/mockmvc/htmlunit/mah.adoc#spring-mvc-test-server-htmlunit-mah-usage[HtmlUnit test] by using the Page Object Pattern. As mentioned previously, we can use the Page Object Pattern with HtmlUnit and WebDriver, but it is even easier with Geb. Consider our new Groovy-based `CreateMessagePage` implementation: diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit/mah.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/mah.adoc similarity index 80% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit/mah.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/mah.adoc index 7ef8a8dbcfbf..97dd4171b956 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit/mah.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/mah.adoc @@ -1,15 +1,14 @@ -[[spring-mvc-test-server-htmlunit-mah]] +[[mockmvc-server-htmlunit-mah]] = MockMvc and HtmlUnit This section describes how to integrate MockMvc and HtmlUnit. Use this option if you want to use the raw HtmlUnit libraries. -[[spring-mvc-test-server-htmlunit-mah-setup]] +[[mockmvc-server-htmlunit-mah-setup]] == MockMvc and HtmlUnit Setup First, make sure that you have included a test dependency on -`net.sourceforge.htmlunit:htmlunit`. In order to use HtmlUnit with Apache HttpComponents -4.5+, you need to use HtmlUnit 2.18 or higher. +`org.htmlunit:htmlunit`. We can easily create an HtmlUnit `WebClient` that integrates with MockMvc by using the `MockMvcWebClientBuilder`, as follows: @@ -18,7 +17,7 @@ We can easily create an HtmlUnit `WebClient` that integrates with MockMvc by usi ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebClient webClient; @@ -32,7 +31,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- lateinit var webClient: WebClient @@ -46,14 +45,14 @@ Kotlin:: ====== NOTE: This is a simple example of using `MockMvcWebClientBuilder`. For advanced usage, -see xref:testing/spring-mvc-test-framework/server-htmlunit/mah.adoc#spring-mvc-test-server-htmlunit-mah-advanced-builder[Advanced `MockMvcWebClientBuilder`]. +see xref:testing/mockmvc/htmlunit/mah.adoc#spring-mvc-test-server-htmlunit-mah-advanced-builder[Advanced `MockMvcWebClientBuilder`]. This ensures that any URL that references `localhost` as the server is directed to our `MockMvc` instance without the need for a real HTTP connection. Any other URL is requested by using a network connection, as normal. This lets us easily test the use of CDNs. -[[spring-mvc-test-server-htmlunit-mah-usage]] +[[mockmvc-server-htmlunit-mah-usage]] == MockMvc and HtmlUnit Usage Now we can use HtmlUnit as we normally would but without the need to deploy our @@ -64,21 +63,21 @@ message with the following: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HtmlPage createMsgFormPage = webClient.getPage("http://localhost/messages/form"); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val createMsgFormPage = webClient.getPage("http://localhost/messages/form") ---- ====== NOTE: The default context path is `""`. Alternatively, we can specify the context path, -as described in xref:testing/spring-mvc-test-framework/server-htmlunit/mah.adoc#spring-mvc-test-server-htmlunit-mah-advanced-builder[Advanced `MockMvcWebClientBuilder`]. +as described in xref:testing/mockmvc/htmlunit/mah.adoc#spring-mvc-test-server-htmlunit-mah-advanced-builder[Advanced `MockMvcWebClientBuilder`]. Once we have a reference to the `HtmlPage`, we can then fill out the form and submit it to create a message, as the following example shows: @@ -87,7 +86,7 @@ to create a message, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HtmlForm form = createMsgFormPage.getHtmlElementById("messageForm"); HtmlTextInput summaryInput = createMsgFormPage.getHtmlElementById("summary"); @@ -100,7 +99,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val form = createMsgFormPage.getHtmlElementById("messageForm") val summaryInput = createMsgFormPage.getHtmlElementById("summary") @@ -119,7 +118,7 @@ assertions use the {assertj-docs}[AssertJ] library: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- assertThat(newMessagePage.getUrl().toString()).endsWith("/messages/123"); String id = newMessagePage.getHtmlElementById("id").getTextContent(); @@ -132,7 +131,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- assertThat(newMessagePage.getUrl().toString()).endsWith("/messages/123") val id = newMessagePage.getHtmlElementById("id").getTextContent() @@ -145,7 +144,7 @@ Kotlin:: ====== The preceding code improves on our -xref:testing/spring-mvc-test-framework/server-htmlunit/why.adoc#spring-mvc-test-server-htmlunit-mock-mvc-test[MockMvc test] in a number of ways. +xref:testing/mockmvc/htmlunit/why.adoc#spring-mvc-test-server-htmlunit-mock-mvc-test[MockMvc test] in a number of ways. First, we no longer have to explicitly verify our form and then create a request that looks like the form. Instead, we request the form, fill it out, and submit it, thereby significantly reducing the overhead. @@ -157,7 +156,7 @@ the behavior of JavaScript within our pages. See the https://htmlunit.sourceforge.io/gettingStarted.html[HtmlUnit documentation] for additional information about using HtmlUnit. -[[spring-mvc-test-server-htmlunit-mah-advanced-builder]] +[[mockmvc-server-htmlunit-mah-advanced-builder]] == Advanced `MockMvcWebClientBuilder` In the examples so far, we have used `MockMvcWebClientBuilder` in the simplest way @@ -168,7 +167,7 @@ the Spring TestContext Framework. This approach is repeated in the following exa ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebClient webClient; @@ -182,7 +181,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- lateinit var webClient: WebClient @@ -201,7 +200,7 @@ We can also specify additional configuration options, as the following example s ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebClient webClient; @@ -221,7 +220,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- lateinit var webClient: WebClient @@ -247,7 +246,7 @@ instance separately and supplying it to the `MockMvcWebClientBuilder`, as follow ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- MockMvc mockMvc = MockMvcBuilders .webAppContextSetup(context) @@ -266,7 +265,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Not possible in Kotlin until {kotlin-issues}/KT-22208 is fixed ---- @@ -276,5 +275,5 @@ This is more verbose, but, by building the `WebClient` with a `MockMvc` instance the full power of MockMvc at our fingertips. TIP: For additional information on creating a `MockMvc` instance, see -xref:testing/spring-mvc-test-framework/server-setup-options.adoc[Setup Choices]. +xref:testing/mockmvc/hamcrest/setup.adoc[Configuring MockMvc]. diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit/webdriver.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/webdriver.adoc similarity index 84% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit/webdriver.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/webdriver.adoc index 2c9bff2f937d..175af0f55eec 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit/webdriver.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/webdriver.adoc @@ -1,11 +1,11 @@ -[[spring-mvc-test-server-htmlunit-webdriver]] +[[mockmvc-server-htmlunit-webdriver]] = MockMvc and WebDriver In the previous sections, we have seen how to use MockMvc in conjunction with the raw HtmlUnit APIs. In this section, we use additional abstractions within the Selenium https://docs.seleniumhq.org/projects/webdriver/[WebDriver] to make things even easier. -[[spring-mvc-test-server-htmlunit-webdriver-why]] +[[mockmvc-server-htmlunit-webdriver-why]] == Why WebDriver and MockMvc? We can already use HtmlUnit and MockMvc, so why would we want to use WebDriver? The @@ -30,7 +30,7 @@ following repeated in multiple places within our tests: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HtmlTextInput summaryInput = currentPage.getHtmlElementById("summary"); summaryInput.setValueAttribute(summary); @@ -38,7 +38,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val summaryInput = currentPage.getHtmlElementById("summary") summaryInput.setValueAttribute(summary) @@ -53,7 +53,7 @@ ideally extract this code into its own method, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public HtmlPage createMessage(HtmlPage currentPage, String summary, String text) { setSummary(currentPage, summary); @@ -68,7 +68,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun createMessage(currentPage: HtmlPage, summary:String, text:String) :HtmlPage{ setSummary(currentPage, summary); @@ -91,7 +91,7 @@ represents the `HtmlPage` we are currently on, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class CreateMessagePage { @@ -128,7 +128,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class CreateMessagePage(private val currentPage: HtmlPage) { @@ -162,11 +162,11 @@ https://github.com/SeleniumHQ/selenium/wiki/PageObjects[Page Object Pattern]. Wh can certainly do this with HtmlUnit, WebDriver provides some tools that we explore in the following sections to make this pattern much easier to implement. -[[spring-mvc-test-server-htmlunit-webdriver-setup]] +[[mockmvc-server-htmlunit-webdriver-setup]] == MockMvc and WebDriver Setup -To use Selenium WebDriver with the Spring MVC Test framework, make sure that your project -includes a test dependency on `org.seleniumhq.selenium:selenium-htmlunit-driver`. +To use Selenium WebDriver with `MockMvc`, make sure that your project includes a test +dependency on `org.seleniumhq.selenium:selenium-htmlunit3-driver`. We can easily create a Selenium WebDriver that integrates with MockMvc by using the `MockMvcHtmlUnitDriverBuilder` as the following example shows: @@ -175,7 +175,7 @@ We can easily create a Selenium WebDriver that integrates with MockMvc by using ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebDriver driver; @@ -189,7 +189,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- lateinit var driver: WebDriver @@ -203,14 +203,14 @@ Kotlin:: ====== NOTE: This is a simple example of using `MockMvcHtmlUnitDriverBuilder`. For more advanced -usage, see xref:testing/spring-mvc-test-framework/server-htmlunit/webdriver.adoc#spring-mvc-test-server-htmlunit-webdriver-advanced-builder[Advanced `MockMvcHtmlUnitDriverBuilder`]. +usage, see xref:testing/mockmvc/htmlunit/webdriver.adoc#spring-mvc-test-server-htmlunit-webdriver-advanced-builder[Advanced `MockMvcHtmlUnitDriverBuilder`]. The preceding example ensures that any URL that references `localhost` as the server is directed to our `MockMvc` instance without the need for a real HTTP connection. Any other URL is requested by using a network connection, as normal. This lets us easily test the use of CDNs. -[[spring-mvc-test-server-htmlunit-webdriver-usage]] +[[mockmvc-server-htmlunit-webdriver-usage]] == MockMvc and WebDriver Usage Now we can use WebDriver as we normally would but without the need to deploy our @@ -222,14 +222,14 @@ message with the following: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- CreateMessagePage page = CreateMessagePage.to(driver); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val page = CreateMessagePage.to(driver) ---- @@ -243,7 +243,7 @@ We can then fill out the form and submit it to create a message, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ViewMessagePage viewMessagePage = page.createMessage(ViewMessagePage.class, expectedSummary, expectedText); @@ -251,7 +251,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val viewMessagePage = page.createMessage(ViewMessagePage::class, expectedSummary, expectedText) @@ -259,9 +259,9 @@ Kotlin:: ====== -- -This improves on the design of our xref:testing/spring-mvc-test-framework/server-htmlunit/mah.adoc#spring-mvc-test-server-htmlunit-mah-usage[HtmlUnit test] +This improves on the design of our xref:testing/mockmvc/htmlunit/mah.adoc#spring-mvc-test-server-htmlunit-mah-usage[HtmlUnit test] by leveraging the Page Object Pattern. As we mentioned in -xref:testing/spring-mvc-test-framework/server-htmlunit/webdriver.adoc#spring-mvc-test-server-htmlunit-webdriver-why[Why WebDriver and MockMvc?], we can use the Page Object Pattern +xref:testing/mockmvc/htmlunit/webdriver.adoc#spring-mvc-test-server-htmlunit-webdriver-why[Why WebDriver and MockMvc?], we can use the Page Object Pattern with HtmlUnit, but it is much easier with WebDriver. Consider the following `CreateMessagePage` implementation: @@ -270,7 +270,7 @@ with HtmlUnit, but it is much easier with WebDriver. Consider the following ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class CreateMessagePage extends AbstractPage { // <1> @@ -317,7 +317,7 @@ annotation to look up our submit button with a `css` selector (`input[type=submi Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class CreateMessagePage(private val driver: WebDriver) : AbstractPage(driver) { // <1> @@ -369,7 +369,7 @@ assertions use the {assertj-docs}[AssertJ] assertion library: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- assertThat(viewMessagePage.getMessage()).isEqualTo(expectedMessage); assertThat(viewMessagePage.getSuccess()).isEqualTo("Successfully created a new message"); @@ -377,7 +377,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- assertThat(viewMessagePage.message).isEqualTo(expectedMessage) assertThat(viewMessagePage.success).isEqualTo("Successfully created a new message") @@ -393,7 +393,7 @@ example, it exposes a method that returns a `Message` object: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public Message getMessage() throws ParseException { Message message = new Message(); @@ -407,7 +407,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun getMessage() = Message(getId(), getCreated(), getSummary(), getText()) ---- @@ -424,7 +424,7 @@ as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @AfterEach void destroy() { @@ -436,7 +436,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @AfterEach fun destroy() { @@ -451,7 +451,7 @@ Kotlin:: For additional information on using WebDriver, see the Selenium https://github.com/SeleniumHQ/selenium/wiki/Getting-Started[WebDriver documentation]. -[[spring-mvc-test-server-htmlunit-webdriver-advanced-builder]] +[[mockmvc-server-htmlunit-webdriver-advanced-builder]] == Advanced `MockMvcHtmlUnitDriverBuilder` In the examples so far, we have used `MockMvcHtmlUnitDriverBuilder` in the simplest way @@ -462,7 +462,7 @@ the Spring TestContext Framework. This approach is repeated here, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebDriver driver; @@ -476,7 +476,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- lateinit var driver: WebDriver @@ -495,7 +495,7 @@ We can also specify additional configuration options, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebDriver driver; @@ -515,7 +515,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- lateinit var driver: WebDriver @@ -541,7 +541,7 @@ instance separately and supplying it to the `MockMvcHtmlUnitDriverBuilder`, as f ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- MockMvc mockMvc = MockMvcBuilders .webAppContextSetup(context) @@ -560,7 +560,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Not possible in Kotlin until {kotlin-issues}/KT-22208 is fixed ---- @@ -570,5 +570,5 @@ This is more verbose, but, by building the `WebDriver` with a `MockMvc` instance the full power of MockMvc at our fingertips. TIP: For additional information on creating a `MockMvc` instance, see -xref:testing/spring-mvc-test-framework/server-setup-options.adoc[Setup Choices]. +xref:testing/mockmvc/hamcrest/setup.adoc[Configuring MockMvc]. diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit/why.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/why.adoc similarity index 86% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit/why.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/why.adoc index 04493364f859..710a310f1d5d 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-htmlunit/why.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/why.adoc @@ -1,4 +1,4 @@ -[[spring-mvc-test-server-htmlunit-why]] +[[mockmvc-server-htmlunit-why]] = Why HtmlUnit Integration? The most obvious question that comes to mind is "`Why do I need this?`" The answer is @@ -12,7 +12,7 @@ With Spring MVC Test, we can easily test if we are able to create a `Message`, a ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- MockHttpServletRequestBuilder createMessage = post("/messages/") .param("summary", "Spring Rocks") @@ -25,7 +25,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Test fun test() { @@ -67,7 +67,7 @@ naive attempt might resemble the following: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- mockMvc.perform(get("/messages/form")) .andExpect(xpath("//input[@name='summary']").exists()) @@ -76,7 +76,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- mockMvc.get("/messages/form").andExpect { xpath("//input[@name='summary']") { exists() } @@ -87,15 +87,15 @@ Kotlin:: This test has some obvious drawbacks. If we update our controller to use the parameter `message` instead of `text`, our form test continues to pass, even though the HTML form -is out of synch with the controller. To resolve this we can combine our two tests, as +is out of sync with the controller. To resolve this we can combine our two tests, as follows: [tabs] ====== Java:: + -[[spring-mvc-test-server-htmlunit-mock-mvc-test]] -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[[mockmvc-server-htmlunit-mock-mvc-test]] +[source,java,indent=0,subs="verbatim,quotes"] ---- String summaryParamName = "summary"; String textParamName = "text"; @@ -114,7 +114,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val summaryParamName = "summary"; val textParamName = "text"; @@ -151,7 +151,7 @@ the input to a user for creating a message. In addition, our form view can poten use additional resources that impact the behavior of the page, such as JavaScript validation. -[[spring-mvc-test-server-htmlunit-why-integration]] +[[mockmvc-server-htmlunit-why-integration]] == Integration Testing to the Rescue? To resolve the issues mentioned earlier, we could perform end-to-end integration testing, @@ -181,23 +181,23 @@ and without side effects. We can then implement a small number of true end-to-en integration tests that validate simple workflows to ensure that everything works together properly. -[[spring-mvc-test-server-htmlunit-why-mockmvc]] +[[mockmvc-server-htmlunit-why-mockmvc]] == Enter HtmlUnit Integration So how can we achieve a balance between testing the interactions of our pages and still retain good performance within our test suite? The answer is: "`By integrating MockMvc with HtmlUnit.`" -[[spring-mvc-test-server-htmlunit-options]] +[[mockmvc-server-htmlunit-options]] == HtmlUnit Integration Options You have a number of options when you want to integrate MockMvc with HtmlUnit: -* xref:testing/spring-mvc-test-framework/server-htmlunit/mah.adoc[MockMvc and HtmlUnit]: Use this option if you +* xref:testing/mockmvc/htmlunit/mah.adoc[MockMvc and HtmlUnit]: Use this option if you want to use the raw HtmlUnit libraries. -* xref:testing/spring-mvc-test-framework/server-htmlunit/webdriver.adoc[MockMvc and WebDriver]: Use this option to +* xref:testing/mockmvc/htmlunit/webdriver.adoc[MockMvc and WebDriver]: Use this option to ease development and reuse code between integration and end-to-end testing. -* xref:testing/spring-mvc-test-framework/server-htmlunit/geb.adoc[MockMvc and Geb]: Use this option if you want to +* xref:testing/mockmvc/htmlunit/geb.adoc[MockMvc and Geb]: Use this option if you want to use Groovy for testing, ease development, and reuse code between integration and end-to-end testing. diff --git a/framework-docs/modules/ROOT/pages/testing/mockmvc/overview.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/overview.adoc new file mode 100644 index 000000000000..a1571c1bd938 --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/overview.adoc @@ -0,0 +1,24 @@ +[[mockmvc-overview]] += Overview +:page-section-summary-toc: 1 + +You can write plain unit tests for Spring MVC by instantiating a controller, injecting it +with dependencies, and calling its methods. However such tests do not verify request +mappings, data binding, message conversion, type conversion, or validation and also do +not involve any of the supporting `@InitBinder`, `@ModelAttribute`, or +`@ExceptionHandler` methods. + +`MockMvc` aims to provide more complete testing support for Spring MVC controllers +without a running server. It does that by invoking the `DispatcherServlet` and passing +xref:testing/unit.adoc#mock-objects-servlet["mock" implementations of the Servlet API] +from the `spring-test` module which replicates the full Spring MVC request handling +without a running server. + +MockMvc is a server-side test framework that lets you verify most of the functionality of +a Spring MVC application using lightweight and targeted tests. You can use it on its own +to perform requests and to verify responses using Hamcrest or through `MockMvcTester` +which provides a fluent API using AssertJ. You can also use it through the +xref:testing/webtestclient.adoc[WebTestClient] API with MockMvc plugged in as the server +to handle requests. + + diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-resources.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/resources.adoc similarity index 84% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-resources.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/resources.adoc index cb9c8e97e8d6..f749cead6f09 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-resources.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/resources.adoc @@ -1,8 +1,8 @@ -[[spring-mvc-test-server-resources]] +[[mockmvc-server-resources]] = Further Examples :page-section-summary-toc: 1 -The framework's own tests include +The framework's own test suite includes {spring-framework-code}/spring-test/src/test/java/org/springframework/test/web/servlet/samples[ many sample tests] intended to show how to use MockMvc on its own or through the {spring-framework-code}/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client[ diff --git a/framework-docs/modules/ROOT/pages/testing/mockmvc/setup-options.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/setup-options.adoc new file mode 100644 index 000000000000..5a12a668c59c --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/setup-options.adoc @@ -0,0 +1,32 @@ +[[mockmvc-server-setup-options]] += Setup Options + +MockMvc can be set up in one of two ways. + +`WebApplicationContext` :: + Point to Spring configuration with Spring MVC and controller infrastructure in it. +Standalone :: + Point directly to the controllers you want to test and programmatically configure Spring + MVC infrastructure. + +Which setup option should you use? + +A `WebApplicationContext`-based test loads your actual Spring MVC configuration, +resulting in a more complete integration test. Since the TestContext framework caches the +loaded Spring configuration, it helps keep tests running fast, even as you introduce more +tests in your test suite using the same configuration. Furthermore, you can override +services used by your controller using `@MockitoBean` or `@TestBean` to remain focused on +testing the web layer. + +A standalone test, on the other hand, is a little closer to a unit test. It tests one +controller at a time. You can manually inject the controller with mock dependencies, and +it does not involve loading Spring configuration. Such tests are more focused on style +and make it easier to see which controller is being tested, whether any specific Spring +MVC configuration is required to work, and so on. The standalone setup is also a very +convenient way to write ad-hoc tests to verify specific behavior or to debug an issue. + +As with most "integration versus unit testing" debates, there is no right or wrong +answer. However, using standalone tests does imply the need for additional integration +tests to verify your Spring MVC configuration. Alternatively, you can write all your +tests with a `WebApplicationContext`, so that they always test against your actual Spring +MVC configuration. diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/vs-end-to-end-integration-tests.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/vs-end-to-end-integration-tests.adoc similarity index 76% rename from framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/vs-end-to-end-integration-tests.adoc rename to framework-docs/modules/ROOT/pages/testing/mockmvc/vs-end-to-end-integration-tests.adoc index 9b3d38ccff7d..4a4e2eee7a72 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/vs-end-to-end-integration-tests.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/vs-end-to-end-integration-tests.adoc @@ -1,4 +1,4 @@ -[[spring-mvc-test-vs-end-to-end-integration-tests]] +[[mockmvc-vs-end-to-end-integration-tests]] = MockMvc vs End-to-End Tests MockMvc is built on Servlet API mock implementations from the @@ -10,18 +10,18 @@ The easiest way to think about this is by starting with a blank `MockHttpServlet Whatever you add to it is what the request becomes. Things that may catch you by surprise are that there is no context path by default; no `jsessionid` cookie; no forwarding, error, or async dispatches; and, therefore, no actual JSP rendering. Instead, -"`forwarded`" and "`redirected`" URLs are saved in the `MockHttpServletResponse` and can +"forwarded" and "redirected" URLs are saved in the `MockHttpServletResponse` and can be asserted with expectations. This means that, if you use JSPs, you can verify the JSP page to which the request was forwarded, but no HTML is rendered. In other words, the JSP is not invoked. Note, -however, that all other rendering technologies that do not rely on forwarding, such as +however, that all other rendering technologies which do not rely on forwarding, such as Thymeleaf and Freemarker, render HTML to the response body as expected. The same is true for rendering JSON, XML, and other formats through `@ResponseBody` methods. Alternatively, you may consider the full end-to-end integration testing support from Spring Boot with `@SpringBootTest`. See the -{spring-boot-docs}/spring-boot-features.html#boot-features-testing[Spring Boot Reference Guide]. +{spring-boot-docs-ref}/testing/spring-boot-applications.html[Spring Boot Reference Guide]. There are pros and cons for each approach. The options provided in Spring MVC Test are different stops on the scale from classic unit testing to full integration testing. To be @@ -30,17 +30,17 @@ testing, but they are a little closer to it. For example, you can isolate the we by injecting mocked services into controllers, in which case you are testing the web layer only through the `DispatcherServlet` but with actual Spring configuration, as you might test the data access layer in isolation from the layers above it. Also, you can use -the stand-alone setup, focusing on one controller at a time and manually providing the +the standalone setup, focusing on one controller at a time and manually providing the configuration required to make it work. Another important distinction when using Spring MVC Test is that, conceptually, such -tests are the server-side, so you can check what handler was used, if an exception was -handled with a HandlerExceptionResolver, what the content of the model is, what binding +tests are server-side tests, so you can check what handler was used, if an exception was +handled with a `HandlerExceptionResolver`, what the content of the model is, what binding errors there were, and other details. That means that it is easier to write expectations, since the server is not an opaque box, as it is when testing it through an actual HTTP -client. This is generally an advantage of classic unit testing: It is easier to write, +client. This is generally an advantage of classic unit testing: it is easier to write, reason about, and debug but does not replace the need for full integration tests. At the same time, it is important not to lose sight of the fact that the response is the most -important thing to check. In short, there is room here for multiple styles and strategies +important thing to check. In short, there is room for multiple styles and strategies of testing even within the same project. diff --git a/framework-docs/modules/ROOT/pages/testing/resources.adoc b/framework-docs/modules/ROOT/pages/testing/resources.adoc index 9b6b5d61b99f..63c488057edc 100644 --- a/framework-docs/modules/ROOT/pages/testing/resources.adoc +++ b/framework-docs/modules/ROOT/pages/testing/resources.adoc @@ -2,31 +2,38 @@ = Further Resources See the following resources for more information about testing: -* https://www.junit.org/[JUnit]: "A programmer-friendly testing framework for Java and the JVM". - Used by the Spring Framework in its test suite and supported in the +https://www.junit.org/[JUnit] :: + "A programmer-friendly testing framework for Java and the JVM". Used by the Spring + Framework in its test suite and supported in the xref:testing/testcontext-framework.adoc[Spring TestContext Framework]. -* https://testng.org/[TestNG]: A testing framework inspired by JUnit with added support - for test groups, data-driven testing, distributed testing, and other features. Supported - in the xref:testing/testcontext-framework.adoc[Spring TestContext Framework] -* {assertj-docs}[AssertJ]: "Fluent assertions for Java", - including support for Java 8 lambdas, streams, and numerous other features. -* https://en.wikipedia.org/wiki/Mock_Object[Mock Objects]: Article in Wikipedia. -* http://www.mockobjects.com/[MockObjects.com]: Web site dedicated to mock objects, a - technique for improving the design of code within test-driven development. -* https://mockito.github.io[Mockito]: Java mock library based on the - http://xunitpatterns.com/Test%20Spy.html[Test Spy] pattern. Used by the Spring Framework - in its test suite. -* https://easymock.org/[EasyMock]: Java library "that provides Mock Objects for - interfaces (and objects through the class extension) by generating them on the fly using - Java's proxy mechanism." -* https://jmock.org/[JMock]: Library that supports test-driven development of Java code - with mock objects. -* https://www.dbunit.org/[DbUnit]: JUnit extension (also usable with Ant and Maven) that - is targeted at database-driven projects and, among other things, puts your database into - a known state between test runs. -* {testcontainers-site}[Testcontainers]: Java library that supports JUnit - tests, providing lightweight, throwaway instances of common databases, Selenium web - browsers, or anything else that can run in a Docker container. -* https://sourceforge.net/projects/grinder/[The Grinder]: Java load testing framework. -* https://github.com/Ninja-Squad/springmockk[SpringMockK]: Support for Spring Boot - integration tests written in Kotlin using https://mockk.io/[MockK] instead of Mockito. +https://testng.org/[TestNG] :: + A testing framework inspired by JUnit with added support for test groups, data-driven + testing, distributed testing, and other features. Supported in the + xref:testing/testcontext-framework.adoc[Spring TestContext Framework]. +{assertj-docs}[AssertJ] :: + "Fluent assertions for Java", including support for Java 8 lambdas, streams, and + numerous other features. Supported in Spring's + xref:testing/mockmvc/assertj.adoc[MockMvc testing support]. +https://en.wikipedia.org/wiki/Mock_Object[Mock Objects] :: + Article in Wikipedia. +https://site.mockito.org[Mockito] :: + Java mock library based on the http://xunitpatterns.com/Test%20Spy.html[Test Spy] + pattern. Used by the Spring Framework in its test suite. +https://easymock.org/[EasyMock] :: + Java library "that provides Mock Objects for interfaces (and objects through the class + extension) by generating them on the fly using Java's proxy mechanism." +https://jmock.org/[JMock] :: + Library that supports test-driven development of Java code with mock objects. +https://www.dbunit.org/[DbUnit] :: + JUnit extension (also usable with Ant and Maven) that is targeted at database-driven + projects and, among other things, puts your database into a known state between test + runs. +{testcontainers-site}[Testcontainers] :: + Java library that supports JUnit tests, providing lightweight, throwaway instances of + common databases, Selenium web browsers, or anything else that can run in a Docker + container. +https://sourceforge.net/projects/grinder/[The Grinder] :: + Java load testing framework. +https://github.com/Ninja-Squad/springmockk[SpringMockK] :: + Support for Spring Boot integration tests written in Kotlin using + https://mockk.io/[MockK] instead of Mockito. diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-client.adoc b/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-client.adoc index e8c56ed11fc0..55738c214f7d 100644 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-client.adoc +++ b/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-client.adoc @@ -10,7 +10,7 @@ example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RestTemplate restTemplate = new RestTemplate(); @@ -24,7 +24,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val restTemplate = RestTemplate() @@ -55,14 +55,14 @@ requests are allowed to come in any order. The following example uses `ignoreExp ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- server = MockRestServiceServer.bindTo(restTemplate).ignoreExpectOrder(true).build(); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- server = MockRestServiceServer.bindTo(restTemplate).ignoreExpectOrder(true).build() ---- @@ -77,7 +77,7 @@ argument that specifies a count range (for example, `once`, `manyTimes`, `max`, ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RestTemplate restTemplate = new RestTemplate(); @@ -92,7 +92,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val restTemplate = RestTemplate() @@ -122,7 +122,7 @@ logic but without running a server. The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build(); this.restTemplate = new RestTemplate(new MockMvcClientHttpRequestFactory(mockMvc)); @@ -132,7 +132,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build() restTemplate = RestTemplate(MockMvcClientHttpRequestFactory(mockMvc)) @@ -149,7 +149,7 @@ of mocking the response. The following example shows how to do that through ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RestTemplate restTemplate = new RestTemplate(); @@ -167,7 +167,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val restTemplate = RestTemplate() @@ -193,7 +193,7 @@ Then we define expectations with two kinds of responses: * a response obtained through a call to the `/quoteOfTheDay` endpoint In the second case, the request is executed through the `ClientHttpRequestFactory` that was -captured earlier. This generates a response that could e.g. come from an actual remote server, +captured earlier. This generates a response that could, for example, come from an actual remote server, depending on how the `RestTemplate` was originally configured. [[spring-mvc-test-client-static-imports]] diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework.adoc b/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework.adoc deleted file mode 100644 index ec1900709d29..000000000000 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework.adoc +++ /dev/null @@ -1,15 +0,0 @@ -[[spring-mvc-test-framework]] -= MockMvc -:page-section-summary-toc: 1 - -The Spring MVC Test framework, also known as MockMvc, provides support for testing Spring -MVC applications. It performs full Spring MVC request handling but via mock request and -response objects instead of a running server. - -MockMvc can be used on its own to perform requests and verify responses. It can also be -used through the xref:testing/webtestclient.adoc[WebTestClient] where MockMvc is plugged in as the server to handle -requests with. The advantage of `WebTestClient` is the option to work with higher level -objects instead of raw data as well as the ability to switch to full, end-to-end HTTP -tests against a live server and use the same test API. - - diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-setup-options.adoc b/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-setup-options.adoc deleted file mode 100644 index 2abf6919d5a5..000000000000 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server-setup-options.adoc +++ /dev/null @@ -1,181 +0,0 @@ -[[spring-mvc-test-server-setup-options]] -= Setup Choices - -MockMvc can be setup in one of two ways. One is to point directly to the controllers you -want to test and programmatically configure Spring MVC infrastructure. The second is to -point to Spring configuration with Spring MVC and controller infrastructure in it. - -To set up MockMvc for testing a specific controller, use the following: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - class MyWebTests { - - MockMvc mockMvc; - - @BeforeEach - void setup() { - this.mockMvc = MockMvcBuilders.standaloneSetup(new AccountController()).build(); - } - - // ... - - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - class MyWebTests { - - lateinit var mockMvc : MockMvc - - @BeforeEach - fun setup() { - mockMvc = MockMvcBuilders.standaloneSetup(AccountController()).build() - } - - // ... - - } ----- -====== - -Or you can also use this setup when testing through the -xref:testing/webtestclient.adoc#webtestclient-controller-config[WebTestClient] which delegates to the same builder -as shown above. - -To set up MockMvc through Spring configuration, use the following: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @SpringJUnitWebConfig(locations = "my-servlet-context.xml") - class MyWebTests { - - MockMvc mockMvc; - - @BeforeEach - void setup(WebApplicationContext wac) { - this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build(); - } - - // ... - - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @SpringJUnitWebConfig(locations = ["my-servlet-context.xml"]) - class MyWebTests { - - lateinit var mockMvc: MockMvc - - @BeforeEach - fun setup(wac: WebApplicationContext) { - mockMvc = MockMvcBuilders.webAppContextSetup(wac).build() - } - - // ... - - } ----- -====== - -Or you can also use this setup when testing through the -xref:testing/webtestclient.adoc#webtestclient-context-config[WebTestClient] which delegates to the same builder -as shown above. - - - -Which setup option should you use? - -The `webAppContextSetup` loads your actual Spring MVC configuration, resulting in a more -complete integration test. Since the TestContext framework caches the loaded Spring -configuration, it helps keep tests running fast, even as you introduce more tests in your -test suite. Furthermore, you can inject mock services into controllers through Spring -configuration to remain focused on testing the web layer. The following example declares -a mock service with Mockito: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - ----- - -You can then inject the mock service into the test to set up and verify your -expectations, as the following example shows: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @SpringJUnitWebConfig(locations = "test-servlet-context.xml") - class AccountTests { - - @Autowired - AccountService accountService; - - MockMvc mockMvc; - - @BeforeEach - void setup(WebApplicationContext wac) { - this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build(); - } - - // ... - - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @SpringJUnitWebConfig(locations = ["test-servlet-context.xml"]) - class AccountTests { - - @Autowired - lateinit var accountService: AccountService - - lateinit var mockMvc: MockMvc - - @BeforeEach - fun setup(wac: WebApplicationContext) { - mockMvc = MockMvcBuilders.webAppContextSetup(wac).build() - } - - // ... - - } ----- -====== - -The `standaloneSetup`, on the other hand, is a little closer to a unit test. It tests one -controller at a time. You can manually inject the controller with mock dependencies, and -it does not involve loading Spring configuration. Such tests are more focused on style -and make it easier to see which controller is being tested, whether any specific Spring -MVC configuration is required to work, and so on. The `standaloneSetup` is also a very -convenient way to write ad-hoc tests to verify specific behavior or to debug an issue. - -As with most "`integration versus unit testing`" debates, there is no right or wrong -answer. However, using the `standaloneSetup` does imply the need for additional -`webAppContextSetup` tests in order to verify your Spring MVC configuration. -Alternatively, you can write all your tests with `webAppContextSetup`, in order to always -test against your actual Spring MVC configuration. - diff --git a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server.adoc b/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server.adoc deleted file mode 100644 index 2368a5a96c41..000000000000 --- a/framework-docs/modules/ROOT/pages/testing/spring-mvc-test-framework/server.adoc +++ /dev/null @@ -1,24 +0,0 @@ -[[spring-mvc-test-server]] -= Overview -:page-section-summary-toc: 1 - -You can write plain unit tests for Spring MVC by instantiating a controller, injecting it -with dependencies, and calling its methods. However such tests do not verify request -mappings, data binding, message conversion, type conversion, validation, and nor -do they involve any of the supporting `@InitBinder`, `@ModelAttribute`, or -`@ExceptionHandler` methods. - -The Spring MVC Test framework, also known as `MockMvc`, aims to provide more complete -testing for Spring MVC controllers without a running server. It does that by invoking -the `DispatcherServlet` and passing -xref:testing/unit.adoc#mock-objects-servlet["`mock`" implementations of the Servlet API] from the -`spring-test` module which replicates the full Spring MVC request handling without -a running server. - -MockMvc is a server side test framework that lets you verify most of the functionality -of a Spring MVC application using lightweight and targeted tests. You can use it on -its own to perform requests and to verify responses, or you can also use it through -the xref:testing/webtestclient.adoc[WebTestClient] API with MockMvc plugged in as the server to handle requests -with. - - diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/application-events.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/application-events.adoc index b27d2d98c5b5..f0f40db96554 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/application-events.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/application-events.adoc @@ -1,7 +1,7 @@ [[testcontext-application-events]] = Application Events -Since Spring Framework 5.3.3, the TestContext framework provides support for recording +The TestContext framework provides support for recording xref:core/beans/context-introduction.adoc#context-functionality-events[application events] published in the `ApplicationContext` so that assertions can be performed against those events within tests. All events published during the execution of a single test are made available via @@ -32,7 +32,7 @@ published while invoking a method in a Spring-managed component: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @SpringJUnitConfig(/* ... */) @RecordApplicationEvents // <1> @@ -60,7 +60,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @SpringJUnitConfig(/* ... */) @RecordApplicationEvents // <1> diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/bean-overriding.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/bean-overriding.adoc new file mode 100644 index 000000000000..a709dd96e432 --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/bean-overriding.adoc @@ -0,0 +1,89 @@ +[[testcontext-bean-overriding]] += Bean Overriding in Tests + +Bean overriding in tests refers to the ability to override specific beans in the +`ApplicationContext` for a test class, by annotating the test class or one or more +non-static fields in the test class. + +NOTE: This feature is intended as a less risky alternative to the practice of registering +a bean via `@Bean` with the `DefaultListableBeanFactory` +`setAllowBeanDefinitionOverriding` flag set to `true`. + +The Spring TestContext framework provides two sets of annotations for bean overriding. + +* xref:testing/annotations/integration-spring/annotation-testbean.adoc[`@TestBean`] +* xref:testing/annotations/integration-spring/annotation-mockitobean.adoc[`@MockitoBean` and `@MockitoSpyBean`] + +The former relies purely on Spring, while the latter set relies on the +https://site.mockito.org/[Mockito] third-party library. + +[[testcontext-bean-overriding-custom]] +== Custom Bean Override Support + +The three annotations mentioned above build upon the `@BeanOverride` meta-annotation and +associated infrastructure, which allows one to define custom bean overriding variants. + +To implement custom bean override support, the following is needed: + +* An annotation meta-annotated with `@BeanOverride` that defines the + `BeanOverrideProcessor` to use +* A custom `BeanOverrideProcessor` implementation +* One or more concrete `BeanOverrideHandler` implementations created by the processor + +The Spring TestContext framework includes implementations of the following APIs that +support bean overriding and are responsible for setting up the rest of the infrastructure. + +* a `BeanFactoryPostProcessor` +* a `ContextCustomizerFactory` +* a `TestExecutionListener` + +The `spring-test` module registers implementations of the latter two +(`BeanOverrideContextCustomizerFactory` and `BeanOverrideTestExecutionListener`) in its +{spring-framework-code}/spring-test/src/main/resources/META-INF/spring.factories[`META-INF/spring.factories` +properties file]. + +The bean overriding infrastructure searches for annotations on test classes as well as +annotations on non-static fields in test classes that are meta-annotated with +`@BeanOverride` and instantiates the corresponding `BeanOverrideProcessor` which is +responsible for creating an appropriate `BeanOverrideHandler`. + +The internal `BeanOverrideBeanFactoryPostProcessor` then uses bean override handlers to +alter the test's `ApplicationContext` by creating, replacing, or wrapping beans as +defined by the corresponding `BeanOverrideStrategy`: + +[[testcontext-bean-overriding-strategy]] +`REPLACE`:: + Replaces the bean. Throws an exception if a corresponding bean does not exist. +`REPLACE_OR_CREATE`:: + Replaces the bean if it exists. Creates a new bean if a corresponding bean does not + exist. +`WRAP`:: + Retrieves the original bean and wraps it. + +[TIP] +==== +Only _singleton_ beans can be overridden. Any attempt to override a non-singleton bean +will result in an exception. + +When replacing a bean created by a `FactoryBean`, the `FactoryBean` itself will be +replaced with a singleton bean corresponding to bean override instance created by the +applicable `BeanOverrideHandler`. + +When wrapping a bean created by a `FactoryBean`, the object created by the `FactoryBean` +will be wrapped, not the `FactoryBean` itself. +==== + +[NOTE] +==== +In contrast to Spring's autowiring mechanism (for example, resolution of an `@Autowired` +field), the bean overriding infrastructure in the TestContext framework has limited +heuristics it can perform to locate a bean. Either the `BeanOverrideProcessor` can compute +the name of the bean to override, or it can be unambiguously selected given the type of +the annotated field and its qualifying annotations. + +Typically, the bean is selected "by type" by the `BeanOverrideFactoryPostProcessor`. +Alternatively, the user can directly provide the bean name in the custom annotation. + +`BeanOverrideProcessor` implementations may also internally compute a bean name based on +a convention or some other method. +==== diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management.adoc index 9aa446ed035e..880f7832e209 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management.adoc @@ -20,7 +20,7 @@ a field or setter method, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig class MyTest { @@ -35,7 +35,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig class MyTest { @@ -57,7 +57,7 @@ the web application context into your test, as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitWebConfig // <1> class MyWebAppTest { @@ -73,7 +73,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitWebConfig // <1> class MyWebAppTest { diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/dynamic-property-sources.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/dynamic-property-sources.adoc index 4705d57cbe64..1eb927a41854 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/dynamic-property-sources.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/dynamic-property-sources.adoc @@ -1,48 +1,75 @@ [[testcontext-ctx-management-dynamic-property-sources]] = Context Configuration with Dynamic Property Sources -As of Spring Framework 5.2.5, the TestContext framework provides support for _dynamic_ -properties via the `@DynamicPropertySource` annotation. This annotation can be used in -integration tests that need to add properties with dynamic values to the set of -`PropertySources` in the `Environment` for the `ApplicationContext` loaded for the -integration test. +The Spring TestContext Framework provides support for _dynamic_ properties via the +`DynamicPropertyRegistry`, the `@DynamicPropertySource` annotation, and the +`DynamicPropertyRegistrar` API. [NOTE] ==== -The `@DynamicPropertySource` annotation and its supporting infrastructure were -originally designed to allow properties from -{testcontainers-site}[Testcontainers] based tests to be exposed easily to -Spring integration tests. However, this feature may also be used with any form of -external resource whose lifecycle is maintained outside the test's `ApplicationContext`. +The dynamic property source infrastructure was originally designed to allow properties +from {testcontainers-site}[Testcontainers] based tests to be exposed easily to Spring +integration tests. However, these features may be used with any form of external resource +whose lifecycle is managed outside the test's `ApplicationContext` or with beans whose +lifecycle is managed by the test's `ApplicationContext`. ==== -In contrast to the xref:testing/testcontext-framework/ctx-management/property-sources.adoc[`@TestPropertySource`] -annotation that is applied at the class level, `@DynamicPropertySource` must be applied -to a `static` method that accepts a single `DynamicPropertyRegistry` argument which is -used to add _name-value_ pairs to the `Environment`. Values are dynamic and provided via -a `Supplier` which is only invoked when the property is resolved. Typically, method -references are used to supply values, as can be seen in the following example which uses -the Testcontainers project to manage a Redis container outside of the Spring -`ApplicationContext`. The IP address and port of the managed Redis container are made -available to components within the test's `ApplicationContext` via the `redis.host` and -`redis.port` properties. These properties can be accessed via Spring's `Environment` -abstraction or injected directly into Spring-managed components – for example, via -`@Value("${redis.host}")` and `@Value("${redis.port}")`, respectively. + +[[testcontext-ctx-management-dynamic-property-sources-precedence]] +== Precedence + +Dynamic properties have higher precedence than those loaded from `@TestPropertySource`, +the operating system's environment, Java system properties, or property sources added by +the application declaratively by using `@PropertySource` or programmatically. Thus, +dynamic properties can be used to selectively override properties loaded via +`@TestPropertySource`, system property sources, and application property sources. + + +[[testcontext-ctx-management-dynamic-property-sources-dynamic-property-registry]] +== `DynamicPropertyRegistry` + +A `DynamicPropertyRegistry` is used to add _name-value_ pairs to the `Environment`. +Values are dynamic and provided via a `Supplier` which is only invoked when the property +is resolved. Typically, method references are used to supply values. The following +sections provide examples of how to use the `DynamicPropertyRegistry`. + + +[[testcontext-ctx-management-dynamic-property-sources-dynamic-property-source]] +== `@DynamicPropertySource` + +In contrast to the +xref:testing/testcontext-framework/ctx-management/property-sources.adoc[`@TestPropertySource`] +annotation that is applied at the class level, `@DynamicPropertySource` can be applied to +`static` methods in integration test classes in order to add properties with dynamic +values to the set of `PropertySources` in the `Environment` for the `ApplicationContext` +loaded for the integration test. + +Methods in integration test classes that are annotated with `@DynamicPropertySource` must +be `static` and must accept a single `DynamicPropertyRegistry` argument. See the +class-level javadoc for `DynamicPropertyRegistry` for further details. [TIP] ==== If you use `@DynamicPropertySource` in a base class and discover that tests in subclasses fail because the dynamic properties change between subclasses, you may need to annotate -your base class with xref:testing/annotations/integration-spring/annotation-dirtiescontext.adoc[`@DirtiesContext`] to -ensure that each subclass gets its own `ApplicationContext` with the correct dynamic +your base class with +xref:testing/annotations/integration-spring/annotation-dirtiescontext.adoc[`@DirtiesContext`] +to ensure that each subclass gets its own `ApplicationContext` with the correct dynamic properties. ==== +The following example uses the Testcontainers project to manage a Redis container outside +of the Spring `ApplicationContext`. The IP address and port of the managed Redis +container are made available to components within the test's `ApplicationContext` via the +`redis.host` and `redis.port` properties. These properties can be accessed via Spring's +`Environment` abstraction or injected directly into Spring-managed components – for +example, via `@Value("${redis.host}")` and `@Value("${redis.port}")`, respectively. + [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(/* ... */) @Testcontainers @@ -65,7 +92,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(/* ... */) @Testcontainers @@ -92,12 +119,71 @@ Kotlin:: ---- ====== -[[precedence]] -== Precedence -Dynamic properties have higher precedence than those loaded from `@TestPropertySource`, -the operating system's environment, Java system properties, or property sources added by -the application declaratively by using `@PropertySource` or programmatically. Thus, -dynamic properties can be used to selectively override properties loaded via -`@TestPropertySource`, system property sources, and application property sources. +[[testcontext-ctx-management-dynamic-property-sources-dynamic-property-registrar]] +== `DynamicPropertyRegistrar` + +As an alternative to implementing `@DynamicPropertySource` methods in integration test +classes, you can register implementations of the `DynamicPropertyRegistrar` API as beans +within the test's `ApplicationContext`. Doing so allows you to support additional use +cases that are not possible with a `@DynamicPropertySource` method. For example, since a +`DynamicPropertyRegistrar` is itself a bean in the `ApplicationContext`, it can interact +with other beans in the context and register dynamic properties that are sourced from +those beans. + +Any bean in a test's `ApplicationContext` that implements the `DynamicPropertyRegistrar` +interface will be automatically detected and eagerly initialized before the singleton +pre-instantiation phase, and the `accept()` methods of such beans will be invoked with a +`DynamicPropertyRegistry` that performs the actual dynamic property registration on +behalf of the registrar. +WARNING: Any interaction with other beans results in eager initialization of those other +beans and their dependencies. + +The following example demonstrates how to implement a `DynamicPropertyRegistrar` as a +lambda expression that registers a dynamic property for the `ApiServer` bean. The +`api.url` property can be accessed via Spring's `Environment` abstraction or injected +directly into other Spring-managed components – for example, via `@Value("${api.url}")`, +and the value of the `api.url` property will be dynamically retrieved from the +`ApiServer` bean. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @Configuration + class TestConfig { + + @Bean + ApiServer apiServer() { + return new ApiServer(); + } + + @Bean + DynamicPropertyRegistrar apiPropertiesRegistrar(ApiServer apiServer) { + return registry -> registry.add("api.url", apiServer::getUrl); + } + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @Configuration + class TestConfig { + + @Bean + fun apiServer(): ApiServer { + return ApiServer() + } + + @Bean + fun apiPropertiesRegistrar(apiServer: ApiServer): DynamicPropertyRegistrar { + return registry -> registry.add("api.url", apiServer::getUrl) + } + } +---- +====== diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/env-profiles.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/env-profiles.adoc index f1d52d53c03a..ea0c505e6643 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/env-profiles.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/env-profiles.adoc @@ -63,7 +63,7 @@ Consider two examples with XML configuration and `@Configuration` classes: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) // ApplicationContext will be loaded from "classpath:/app-config.xml" @@ -83,7 +83,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) // ApplicationContext will be loaded from "classpath:/app-config.xml" @@ -128,7 +128,7 @@ integration test with `@Configuration` classes instead of XML: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @Profile("dev") @@ -147,7 +147,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @Profile("dev") @@ -169,7 +169,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @Profile("production") @@ -185,7 +185,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @Profile("production") @@ -204,7 +204,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @Profile("default") @@ -222,7 +222,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @Profile("default") @@ -243,7 +243,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class TransferServiceConfig { @@ -269,7 +269,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class TransferServiceConfig { @@ -299,7 +299,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig({ TransferServiceConfig.class, @@ -321,7 +321,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig( TransferServiceConfig::class, @@ -366,14 +366,14 @@ automatically inherit the `@ActiveProfiles` configuration from the base class. I following example, the declaration of `@ActiveProfiles` (as well as other annotations) has been moved to an abstract superclass, `AbstractIntegrationTest`: -NOTE: As of Spring Framework 5.3, test configuration may also be inherited from enclosing -classes. See xref:testing/testcontext-framework/support-classes.adoc#testcontext-junit-jupiter-nested-test-configuration[`@Nested` test class configuration] for details. +NOTE: Test configuration may also be inherited from enclosing classes. See +xref:testing/testcontext-framework/support-classes.adoc#testcontext-junit-jupiter-nested-test-configuration[`@Nested` test class configuration] for details. [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig({ TransferServiceConfig.class, @@ -387,7 +387,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig( TransferServiceConfig::class, @@ -404,7 +404,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // "dev" profile inherited from superclass class TransferServiceTest extends AbstractIntegrationTest { @@ -421,7 +421,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // "dev" profile inherited from superclass class TransferServiceTest : AbstractIntegrationTest() { @@ -444,7 +444,7 @@ disable the inheritance of active profiles, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // "dev" profile overridden with "production" @ActiveProfiles(profiles = "production", inheritProfiles = false) @@ -455,7 +455,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // "dev" profile overridden with "production" @ActiveProfiles("production", inheritProfiles = false) @@ -486,7 +486,7 @@ The following example demonstrates how to implement and register a custom ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // "dev" profile overridden programmatically via a custom resolver @ActiveProfiles( @@ -499,7 +499,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // "dev" profile overridden programmatically via a custom resolver @ActiveProfiles( @@ -515,7 +515,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class OperatingSystemActiveProfilesResolver implements ActiveProfilesResolver { @@ -530,7 +530,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class OperatingSystemActiveProfilesResolver : ActiveProfilesResolver { diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/groovy.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/groovy.adoc index 443a89fb38db..43ead704e62b 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/groovy.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/groovy.adoc @@ -18,7 +18,7 @@ The following example shows how to specify Groovy configuration files: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) // ApplicationContext will be loaded from "/AppConfig.groovy" and @@ -32,7 +32,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) // ApplicationContext will be loaded from "/AppConfig.groovy" and @@ -58,7 +58,7 @@ the default: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) // ApplicationContext will be loaded from @@ -72,7 +72,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) // ApplicationContext will be loaded from @@ -101,7 +101,7 @@ The following listing shows how to combine both in an integration test: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) // ApplicationContext will be loaded from @@ -114,7 +114,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) // ApplicationContext will be loaded from diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/hierarchies.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/hierarchies.adoc index 22953ed289cb..c8d57c4276cb 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/hierarchies.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/hierarchies.adoc @@ -39,7 +39,7 @@ lowest context in the hierarchy). The following listing shows this configuration ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) @WebAppConfiguration @@ -58,7 +58,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) @WebAppConfiguration @@ -95,7 +95,7 @@ configuration scenario: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) @WebAppConfiguration @@ -111,7 +111,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) @WebAppConfiguration @@ -146,7 +146,7 @@ The following listing shows this configuration scenario: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) @ContextHierarchy({ @@ -163,7 +163,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) @ContextHierarchy( @@ -192,7 +192,7 @@ shows this configuration scenario: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) @ContextHierarchy({ @@ -212,7 +212,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) @ContextHierarchy( diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/inheritance.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/inheritance.adoc index 0ca98c0965a7..b84d7f93c256 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/inheritance.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/inheritance.adoc @@ -17,8 +17,8 @@ is set to `false`, the resource locations or component classes and the context initializers, respectively, for the test class shadow and effectively replace the configuration defined by superclasses. -NOTE: As of Spring Framework 5.3, test configuration may also be inherited from enclosing -classes. See xref:testing/testcontext-framework/support-classes.adoc#testcontext-junit-jupiter-nested-test-configuration[`@Nested` test class configuration] for details. +NOTE: Test configuration may also be inherited from enclosing classes. See +xref:testing/testcontext-framework/support-classes.adoc#testcontext-junit-jupiter-nested-test-configuration[`@Nested` test class configuration] for details. In the next example, which uses XML resource locations, the `ApplicationContext` for `ExtendedTest` is loaded from `base-config.xml` and `extended-config.xml`, in that order. @@ -30,7 +30,7 @@ another and use both its own configuration file and the superclass's configurati ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) // ApplicationContext will be loaded from "/base-config.xml" @@ -52,7 +52,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) // ApplicationContext will be loaded from "/base-config.xml" @@ -84,7 +84,7 @@ another and use both its own configuration class and the superclass's configurat ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // ApplicationContext will be loaded from BaseConfig @SpringJUnitConfig(BaseConfig.class) // <1> @@ -103,7 +103,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // ApplicationContext will be loaded from BaseConfig @SpringJUnitConfig(BaseConfig::class) // <1> @@ -133,7 +133,7 @@ extend another and use both its own initializer and the superclass's initializer ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // ApplicationContext will be initialized by BaseInitializer @SpringJUnitConfig(initializers = BaseInitializer.class) // <1> @@ -153,7 +153,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // ApplicationContext will be initialized by BaseInitializer @SpringJUnitConfig(initializers = [BaseInitializer::class]) // <1> diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/initializers.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/initializers.adoc index ce05e1a382e6..6eccba2f0a41 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/initializers.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/initializers.adoc @@ -17,7 +17,7 @@ order in which the initializers are invoked depends on whether they implement Sp ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) // ApplicationContext will be loaded from TestConfig @@ -33,7 +33,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) // ApplicationContext will be loaded from TestConfig @@ -59,7 +59,7 @@ files or configuration classes. The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) // ApplicationContext will be initialized by EntireAppInitializer @@ -73,7 +73,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) // ApplicationContext will be initialized by EntireAppInitializer diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/javaconfig.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/javaconfig.adoc index af460ea84f06..ba96142ef9ee 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/javaconfig.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/javaconfig.adoc @@ -10,7 +10,7 @@ that contains references to component classes. The following example shows how t ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) // ApplicationContext will be loaded from AppConfig and TestConfig @@ -23,7 +23,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) // ApplicationContext will be loaded from AppConfig and TestConfig @@ -73,7 +73,7 @@ class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig <1> // ApplicationContext will be loaded from the static nested Config class @@ -105,7 +105,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig <1> // ApplicationContext will be loaded from the nested Config class diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/property-sources.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/property-sources.adoc index 34057860b856..c0990b7396dc 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/property-sources.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/property-sources.adoc @@ -50,7 +50,7 @@ The following example uses a test properties file: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestPropertySource("/test.properties") // <1> @@ -62,7 +62,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestPropertySource("/test.properties") // <1> @@ -106,7 +106,7 @@ The following example sets two inlined properties: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestPropertySource(properties = {"timezone = GMT", "port = 4242"}) // <1> @@ -118,7 +118,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestPropertySource(properties = ["timezone = GMT", "port = 4242"]) // <1> @@ -137,7 +137,7 @@ a text block: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestPropertySource(properties = """ @@ -152,7 +152,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestPropertySource(properties = [""" @@ -168,7 +168,8 @@ Kotlin:: [NOTE] ==== -As of Spring Framework 5.2, `@TestPropertySource` can be used as _repeatable annotation_. +`@TestPropertySource` can be used as _repeatable annotation_. + That means that you can have multiple declarations of `@TestPropertySource` on a single test class, with the `locations` and `properties` from later `@TestPropertySource` annotations overriding those from previous `@TestPropertySource` annotations. @@ -184,7 +185,6 @@ meta-present `@TestPropertySource` annotations. In other words, `locations` and meta-annotation. ==== - [[default-properties-file-detection]] == Default Properties File Detection @@ -195,7 +195,7 @@ if the annotated test class is `com.example.MyTest`, the corresponding default p file is `classpath:com/example/MyTest.properties`. If the default cannot be detected, an `IllegalStateException` is thrown. -[[precedence]] +[[testcontext-ctx-management-property-sources-precedence]] == Precedence Test properties have higher precedence than those defined in the operating system's @@ -218,7 +218,7 @@ to specify properties both in a file and inline: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestPropertySource( @@ -232,7 +232,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestPropertySource("/test.properties", @@ -262,8 +262,8 @@ If the `inheritLocations` or `inheritProperties` attribute in `@TestPropertySour set to `false`, the locations or inlined properties, respectively, for the test class shadow and effectively replace the configuration defined by superclasses. -NOTE: As of Spring Framework 5.3, test configuration may also be inherited from enclosing -classes. See xref:testing/testcontext-framework/support-classes.adoc#testcontext-junit-jupiter-nested-test-configuration[`@Nested` test class configuration] for details. +NOTE: Test configuration may also be inherited from enclosing classes. See +xref:testing/testcontext-framework/support-classes.adoc#testcontext-junit-jupiter-nested-test-configuration[`@Nested` test class configuration] for details. In the next example, the `ApplicationContext` for `BaseTest` is loaded by using only the `base.properties` file as a test property source. In contrast, the `ApplicationContext` @@ -275,7 +275,7 @@ properties in both a subclass and its superclass by using `properties` files: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @TestPropertySource("base.properties") @ContextConfiguration @@ -292,7 +292,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @TestPropertySource("base.properties") @ContextConfiguration @@ -317,7 +317,7 @@ to define properties in both a subclass and its superclass by using inline prope ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @TestPropertySource(properties = "key1 = value1") @ContextConfiguration @@ -334,7 +334,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @TestPropertySource(properties = ["key1 = value1"]) @ContextConfiguration diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/web-mocks.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/web-mocks.adoc index 267baf8e0e0b..578a3f88733d 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/web-mocks.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/web-mocks.adoc @@ -20,9 +20,9 @@ managed per test method by the `ServletTestExecutionListener`. [tabs] ====== -Injecting mocks:: +Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitWebConfig class WacTests { @@ -51,7 +51,7 @@ Injecting mocks:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitWebConfig class WacTests { diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/web.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/web.adoc index cfc6778bbb95..34b6d671f869 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/web.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/web.adoc @@ -29,11 +29,12 @@ The remaining examples in this section show some of the various configuration op loading a `WebApplicationContext`. The following example shows the TestContext framework's support for convention over configuration: +.Conventions [tabs] ====== -Conventions:: +Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) @@ -50,7 +51,7 @@ Conventions:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) @@ -76,11 +77,12 @@ as the `WacTests` class or static nested `@Configuration` classes). The following example shows how to explicitly declare a resource base path with `@WebAppConfiguration` and an XML resource location with `@ContextConfiguration`: +.Default resource semantics [tabs] ====== -Default resource semantics:: +Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) @@ -96,7 +98,7 @@ Default resource semantics:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) @@ -118,11 +120,12 @@ whereas `@ContextConfiguration` resource locations are classpath based. The following example shows that we can override the default resource semantics for both annotations by specifying a Spring resource prefix: +.Explicit resource semantics [tabs] ====== -Explicit resource semantics:: +Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) @@ -138,7 +141,7 @@ Explicit resource semantics:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/xml.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/xml.adoc index 78e998e43ca9..71e0b27f3342 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/xml.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/xml.adoc @@ -14,7 +14,7 @@ path that represents a resource URL (i.e., a path prefixed with `classpath:`, `f ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) // ApplicationContext will be loaded from "/app-config.xml" and @@ -28,7 +28,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) // ApplicationContext will be loaded from "/app-config.xml" and @@ -52,7 +52,7 @@ demonstrated in the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) @ContextConfiguration({"/app-config.xml", "/test-config.xml"}) <1> @@ -64,7 +64,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) @ContextConfiguration("/app-config.xml", "/test-config.xml") // <1> @@ -88,7 +88,7 @@ example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) // ApplicationContext will be loaded from @@ -102,7 +102,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) // ApplicationContext will be loaded from diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/executing-sql.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/executing-sql.adoc index b3bd66ff7c7f..967f774288f5 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/executing-sql.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/executing-sql.adoc @@ -50,7 +50,7 @@ specifies SQL scripts for a test schema and test data, sets the statement separa ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Test void databaseTest() { @@ -66,7 +66,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Test fun databaseTest() { @@ -122,6 +122,9 @@ classpath resource (for example, `"/org/example/schema.sql"`). A path that refer URL (for example, a path prefixed with `classpath:`, `file:`, `http:`) is loaded by using the specified resource protocol. +As of Spring Framework 6.2, paths may contain property placeholders (`${...}`) that will +be replaced by properties stored in the `Environment` of the test's `ApplicationContext`. + The following example shows how to use `@Sql` at the class level and at the method level within a JUnit Jupiter based integration test class: @@ -129,7 +132,7 @@ within a JUnit Jupiter based integration test class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig @Sql("/test-schema.sql") @@ -150,7 +153,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig @Sql("/test-schema.sql") @@ -207,7 +210,7 @@ The following example shows how to use `@Sql` as a repeatable annotation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Test @Sql(scripts = "/test-schema.sql", config = @SqlConfig(commentPrefix = "`")) @@ -219,7 +222,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Test @Sql("/test-schema.sql", config = SqlConfig(commentPrefix = "`")) @@ -241,7 +244,7 @@ but you may need to use `@SqlGroup` for compatibility with other JVM languages. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Test @SqlGroup({ @@ -255,7 +258,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Test @SqlGroup( @@ -280,7 +283,7 @@ database state), you can set the `executionPhase` attribute in `@Sql` to ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Test @Sql( @@ -300,7 +303,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Test @Sql("create-test-data.sql", @@ -326,7 +329,7 @@ declaration to `BEFORE_TEST_CLASS` or `AFTER_TEST_CLASS`, as the following examp ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig @Sql(scripts = "/test-schema.sql", executionPhase = BEFORE_TEST_CLASS) @@ -347,7 +350,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig @Sql("/test-schema.sql", executionPhase = BEFORE_TEST_CLASS) @@ -424,7 +427,7 @@ that uses JUnit Jupiter and transactional tests with `@Sql`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestDatabaseConfig.class) @Transactional @@ -458,7 +461,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestDatabaseConfig::class) @Transactional @@ -495,7 +498,7 @@ details). [[testcontext-executing-sql-declaratively-script-merging]] === Merging and Overriding Configuration with `@SqlMergeMode` -As of Spring Framework 5.2, it is possible to merge method-level `@Sql` declarations with +It is possible to merge method-level `@Sql` declarations with class-level declarations. For example, this allows you to provide the configuration for a database schema or some common test data once per test class and then provide additional, use case specific test data per test method. To enable `@Sql` merging, annotate either diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/fixture-di.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/fixture-di.adoc index 54ce51bffef2..c0d352e4393f 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/fixture-di.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/fixture-di.adoc @@ -58,7 +58,7 @@ uses `@Autowired` for field injection: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) // specifies the Spring configuration to load for this test fixture @@ -79,7 +79,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) // specifies the Spring configuration to load for this test fixture @@ -106,7 +106,7 @@ follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) // specifies the Spring configuration to load for this test fixture @@ -131,7 +131,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension::class) // specifies the Spring configuration to load for this test fixture @@ -192,7 +192,7 @@ method in the superclass as well): ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // ... @@ -207,7 +207,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // ... diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/parallel-test-execution.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/parallel-test-execution.adoc index 6cb00cbcc75f..6e3c268f639b 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/parallel-test-execution.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/parallel-test-execution.adoc @@ -1,10 +1,9 @@ [[testcontext-parallel-test-execution]] = Parallel Test Execution -Spring Framework 5.0 introduced basic support for executing tests in parallel within a -single JVM when using the Spring TestContext Framework. In general, this means that most -test classes or test methods can be run in parallel without any changes to test code -or configuration. +The Spring TestContext Framework provides basic support for executing tests in parallel +within a single JVM. In general, this means that most test classes or test methods can be +run in parallel without any changes to test code or configuration. TIP: For details on how to set up parallel test execution, see the documentation for your testing framework, build tool, or IDE. @@ -17,6 +16,7 @@ for when not to run tests in parallel. Do not run tests in parallel if the tests: * Use Spring Framework's `@DirtiesContext` support. +* Use Spring Framework's `@MockitoBean` or `@MockitoSpyBean` support. * Use Spring Boot's `@MockBean` or `@SpyBean` support. * Use JUnit 4's `@FixMethodOrder` support or any testing framework feature that is designed to ensure that test methods run in a particular order. Note, @@ -45,4 +45,3 @@ the javadoc for {spring-framework-api}/test/context/TestContext.html[`TestContex third-party library that provides a custom `TestContext` implementation, you need to verify that it is suitable for parallel test execution. - diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/support-classes.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/support-classes.adoc index 51731166829f..1ee5856ecfd0 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/support-classes.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/support-classes.adoc @@ -24,7 +24,7 @@ run with the custom Spring `Runner`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RunWith(SpringRunner.class) @TestExecutionListeners({}) @@ -39,7 +39,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RunWith(SpringRunner::class) @TestExecutionListeners @@ -83,7 +83,7 @@ to declare these rules in an integration test: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // Optionally specify a non-Spring Runner via @RunWith(...) @ContextConfiguration @@ -104,7 +104,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Optionally specify a non-Spring Runner via @RunWith(...) @ContextConfiguration @@ -193,7 +193,7 @@ The following code listing shows how to configure a test class to use the ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // Instructs JUnit Jupiter to extend the test with Spring support. @ExtendWith(SpringExtension.class) @@ -210,7 +210,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Instructs JUnit Jupiter to extend the test with Spring support. @ExtendWith(SpringExtension::class) @@ -237,7 +237,7 @@ used in the previous example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // Instructs Spring to register the SpringExtension with JUnit // Jupiter and load an ApplicationContext from TestConfig.class @@ -253,7 +253,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Instructs Spring to register the SpringExtension with JUnit // Jupiter and load an ApplicationContext from TestConfig.class @@ -275,7 +275,7 @@ Similarly, the following example uses `@SpringJUnitWebConfig` to create a ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // Instructs Spring to register the SpringExtension with JUnit // Jupiter and load a WebApplicationContext from TestWebConfig.class @@ -291,7 +291,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Instructs Spring to register the SpringExtension with JUnit // Jupiter and load a WebApplicationContext from TestWebConfig::class @@ -376,7 +376,7 @@ In the following example, Spring injects the `OrderService` bean from the ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig.class) class OrderServiceIntegrationTests { @@ -394,7 +394,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig::class) class OrderServiceIntegrationTests @Autowired constructor(private val orderService: OrderService){ @@ -414,7 +414,7 @@ xref:testing/annotations/integration-junit-jupiter.adoc#integration-testing-anno ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig.class) class OrderServiceIntegrationTests { @@ -431,7 +431,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig::class) class OrderServiceIntegrationTests(val orderService:OrderService) { @@ -455,7 +455,7 @@ loaded from `TestConfig.class` into the `deleteOrder()` test method: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig.class) class OrderServiceIntegrationTests { @@ -469,7 +469,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig::class) class OrderServiceIntegrationTests { @@ -493,7 +493,7 @@ into the `placeOrderRepeatedly()` test method simultaneously. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig.class) class OrderServiceIntegrationTests { @@ -510,7 +510,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig::class) class OrderServiceIntegrationTests { @@ -531,12 +531,8 @@ to the `RepetitionInfo`. [[testcontext-junit-jupiter-nested-test-configuration]] === `@Nested` test class configuration -The _Spring TestContext Framework_ has supported the use of test-related annotations on -`@Nested` test classes in JUnit Jupiter since Spring Framework 5.0; however, until Spring -Framework 5.3 class-level test configuration annotations were not _inherited_ from -enclosing classes like they are from superclasses. - -Spring Framework 5.3 introduced first-class support for inheriting test class +The _Spring TestContext Framework_ supports the use of test-related annotations on `@Nested` +test classes in JUnit Jupiter, including first-class support for inheriting test class configuration from enclosing classes, and such configuration will be inherited by default. To change from the default `INHERIT` mode to `OVERRIDE` mode, you may annotate an individual `@Nested` test class with @@ -546,6 +542,14 @@ any of its subclasses and nested classes. Thus, you may annotate a top-level tes with `@NestedTestConfiguration`, and that will apply to all of its nested test classes recursively. +[TIP] +==== +If you are developing a component that integrates with the Spring TestContext Framework +and needs to support annotation inheritance within enclosing class hierarchies, you must +use the annotation search utilities provided in `TestContextAnnotationUtils` in order to +honor `@NestedTestConfiguration` semantics. +==== + In order to allow development teams to change the default to `OVERRIDE` – for example, for compatibility with Spring Framework 5.0 through 5.2 – the default mode can be changed globally via a JVM system property or a `spring.properties` file in the root of the @@ -565,7 +569,7 @@ which annotations can be inherited in `@Nested` test classes. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig.class) class GreetingServiceTests { @@ -594,7 +598,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig::class) class GreetingServiceTests { diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tel-config.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tel-config.adoc index c4117c5d4d33..d4a41beb20ae 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tel-config.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tel-config.adoc @@ -10,18 +10,22 @@ by default, exactly in the following order: annotation for "`before`" modes. * `ApplicationEventsTestExecutionListener`: Provides support for xref:testing/testcontext-framework/application-events.adoc[`ApplicationEvents`]. +* `BeanOverrideTestExecutionListener`: Provides support for xref:testing/testcontext-framework/bean-overriding.adoc[] . * `DependencyInjectionTestExecutionListener`: Provides dependency injection for the test instance. * `MicrometerObservationRegistryTestExecutionListener`: Provides support for Micrometer's `ObservationRegistry`. * `DirtiesContextTestExecutionListener`: Handles the `@DirtiesContext` annotation for "`after`" modes. +* `CommonCachesTestExecutionListener`: Clears resource caches in the test's + `ApplicationContext` if necessary. * `TransactionalTestExecutionListener`: Provides transactional test execution with default rollback semantics. * `SqlScriptsTestExecutionListener`: Runs SQL scripts configured by using the `@Sql` annotation. * `EventPublishingTestExecutionListener`: Publishes test execution events to the test's `ApplicationContext` (see xref:testing/testcontext-framework/test-execution-events.adoc[Test Execution Events]). +* `MockitoResetTestExecutionListener`: Resets mocks as configured by `@MockitoBean` or `@MockitoSpyBean`. [[testcontext-tel-config-registering-tels]] == Registering `TestExecutionListener` Implementations @@ -43,7 +47,7 @@ following. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // Switch to default listeners @TestExecutionListeners( @@ -57,7 +61,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Switch to default listeners @TestExecutionListeners( @@ -114,7 +118,7 @@ listeners. The following listing demonstrates this style of configuration: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestExecutionListeners({ @@ -133,7 +137,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestExecutionListeners( @@ -181,7 +185,7 @@ be replaced with the following: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestExecutionListeners( @@ -195,7 +199,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration @TestExecutionListeners( diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/test-execution-events.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/test-execution-events.adoc index b83c12736731..922f9e2fb4c9 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/test-execution-events.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/test-execution-events.adoc @@ -1,9 +1,9 @@ [[testcontext-test-execution-events]] = Test Execution Events -The `EventPublishingTestExecutionListener` introduced in Spring Framework 5.2 offers an -alternative approach to implementing a custom `TestExecutionListener`. Components in the -test's `ApplicationContext` can listen to the following events published by the +The `EventPublishingTestExecutionListener` offers an alternative approach to implementing +a custom `TestExecutionListener`. Components in the test's `ApplicationContext` can +listen to the following events published by the `EventPublishingTestExecutionListener`, each of which corresponds to a method in the `TestExecutionListener` API. diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tx.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tx.adoc index f34ad15f9457..875e24a21f8a 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tx.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tx.adoc @@ -106,7 +106,7 @@ a Hibernate-based `UserRepository`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig.class) @Transactional @@ -150,7 +150,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(TestConfig::class) @Transactional @@ -223,7 +223,7 @@ for further details. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration(classes = TestConfig.class) public class ProgrammaticTransactionManagementTests extends @@ -255,7 +255,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ContextConfiguration(classes = [TestConfig::class]) class ProgrammaticTransactionManagementTests : AbstractTransactionalJUnit4SpringContextTests() { @@ -316,7 +316,7 @@ example. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @BeforeTransaction void verifyInitialDatabaseState(@Autowired DataSource dataSource) { @@ -326,7 +326,7 @@ void verifyInitialDatabaseState(@Autowired DataSource dataSource) { Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @BeforeTransaction fun verifyInitialDatabaseState(@Autowired dataSource: DataSource) { @@ -375,7 +375,7 @@ following example shows the relevant annotations: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig @Transactional(transactionManager = "txMgr") @@ -414,7 +414,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig @Transactional(transactionManager = "txMgr") @@ -469,7 +469,7 @@ session: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // ... @@ -497,7 +497,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // ... @@ -530,7 +530,7 @@ The following example shows matching methods for JPA: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // ... @@ -558,7 +558,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // ... @@ -612,7 +612,7 @@ example. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // ... @@ -640,7 +640,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // ... diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/web-scoped-beans.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/web-scoped-beans.adoc index 20e7926a7f59..f716f1d9612b 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/web-scoped-beans.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/web-scoped-beans.adoc @@ -47,11 +47,12 @@ the provided `MockHttpServletRequest`. When the `loginUser()` method is invoked set parameters). We can then perform assertions against the results based on the known inputs for the username and password. The following listing shows how to do so: +.Request-scoped bean test [tabs] ====== -Request-scoped bean test:: +Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitWebConfig class RequestScopedBeanTests { @@ -72,7 +73,7 @@ Request-scoped bean test:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitWebConfig class RequestScopedBeanTests { @@ -124,11 +125,12 @@ the user service has access to the session-scoped `userPreferences` for the curr `MockHttpSession`, and we can perform assertions against the results based on the configured theme. The following example shows how to do so: +.Session-scoped bean test [tabs] ====== -Session-scoped bean test:: +Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitWebConfig class SessionScopedBeanTests { @@ -148,7 +150,7 @@ Session-scoped bean test:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitWebConfig class SessionScopedBeanTests { diff --git a/framework-docs/modules/ROOT/pages/testing/unit.adoc b/framework-docs/modules/ROOT/pages/testing/unit.adoc index d2ce8648b0e9..249e58c07b7c 100644 --- a/framework-docs/modules/ROOT/pages/testing/unit.adoc +++ b/framework-docs/modules/ROOT/pages/testing/unit.adoc @@ -26,7 +26,6 @@ are described in this chapter. Spring includes a number of packages dedicated to mocking: * xref:testing/unit.adoc#mock-objects-env[Environment] -* xref:testing/unit.adoc#mock-objects-jndi[JNDI] * xref:testing/unit.adoc#mock-objects-servlet[Servlet API] * xref:testing/unit.adoc#mock-objects-web-reactive[Spring Web Reactive] @@ -42,20 +41,6 @@ and xref:core/beans/environment.adoc#beans-property-source-abstraction[`Property out-of-container tests for code that depends on environment-specific properties. -[[mock-objects-jndi]] -=== JNDI - -The `org.springframework.mock.jndi` package contains a partial implementation of the JNDI -SPI, which you can use to set up a simple JNDI environment for test suites or stand-alone -applications. If, for example, JDBC `DataSource` instances get bound to the same JNDI -names in test code as they do in a Jakarta EE container, you can reuse both application code -and configuration in testing scenarios without modification. - -WARNING: The mock JNDI support in the `org.springframework.mock.jndi` package is -officially deprecated as of Spring Framework 5.2 in favor of complete solutions from third -parties such as https://github.com/h-thurow/Simple-JNDI[Simple-JNDI]. - - [[mock-objects-servlet]] === Servlet API @@ -68,8 +53,8 @@ or alternative Servlet API mock objects (such as http://www.mockobjects.com[Mock TIP: Since Spring Framework 6.0, the mock objects in `org.springframework.mock.web` are based on the Servlet 6.0 API. -The Spring MVC Test framework builds on the mock Servlet API objects to provide an -integration testing framework for Spring MVC. See xref:testing/spring-mvc-test-framework.adoc[MockMvc]. +MockMvc builds on the mock Servlet API objects to provide an integration testing +framework for Spring MVC. See xref:testing/mockmvc.adoc[MockMvc]. [[mock-objects-web-reactive]] @@ -165,4 +150,4 @@ combined with `MockHttpServletRequest`, `MockHttpSession`, and so on from Spring xref:testing/unit.adoc#mock-objects-servlet[Servlet API mocks]. For thorough integration testing of your Spring MVC and REST `Controller` classes in conjunction with your `WebApplicationContext` configuration for Spring MVC, use the -xref:testing/spring-mvc-test-framework.adoc[Spring MVC Test Framework] instead. +xref:testing/mockmvc.adoc[MockMvc] instead. diff --git a/framework-docs/modules/ROOT/pages/testing/webtestclient.adoc b/framework-docs/modules/ROOT/pages/testing/webtestclient.adoc index 7f0fa031ef80..2642be67edf7 100644 --- a/framework-docs/modules/ROOT/pages/testing/webtestclient.adoc +++ b/framework-docs/modules/ROOT/pages/testing/webtestclient.adoc @@ -33,7 +33,7 @@ to handle requests: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebTestClient client = WebTestClient.bindToController(new TestController()).build(); @@ -41,7 +41,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val client = WebTestClient.bindToController(TestController()).build() ---- @@ -51,13 +51,13 @@ For Spring MVC, use the following which delegates to the {spring-framework-api}/test/web/servlet/setup/StandaloneMockMvcBuilder.html[StandaloneMockMvcBuilder] to load infrastructure equivalent to the xref:web/webmvc/mvc-config.adoc[WebMvc Java config], registers the given controller(s), and creates an instance of -xref:testing/spring-mvc-test-framework.adoc[MockMvc] to handle requests: +xref:testing/mockmvc.adoc[MockMvc] to handle requests: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebTestClient client = MockMvcWebTestClient.bindToController(new TestController()).build(); @@ -65,7 +65,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val client = MockMvcWebTestClient.bindToController(TestController()).build() ---- @@ -89,7 +89,7 @@ requests: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(WebConfig.class) // <1> class MyTests { @@ -108,7 +108,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @SpringJUnitConfig(WebConfig::class) // <1> class MyTests { @@ -127,15 +127,15 @@ Kotlin:: ====== For Spring MVC, use the following where the Spring `ApplicationContext` is passed to -{spring-framework-api}/test/web/servlet/setup/MockMvcBuilders.html#webAppContextSetup-org.springframework.web.context.WebApplicationContext-[MockMvcBuilders.webAppContextSetup] -to create a xref:testing/spring-mvc-test-framework.adoc[MockMvc] instance to handle +{spring-framework-api}/test/web/servlet/setup/MockMvcBuilders.html#webAppContextSetup(org.springframework.web.context.WebApplicationContext)[MockMvcBuilders.webAppContextSetup] +to create a xref:testing/mockmvc.adoc[MockMvc] instance to handle requests: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) @WebAppConfiguration("classpath:META-INF/web-resources") // <1> @@ -162,7 +162,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ExtendWith(SpringExtension.class) @WebAppConfiguration("classpath:META-INF/web-resources") // <1> @@ -193,7 +193,7 @@ Kotlin:: [[webtestclient-fn-config]] === Bind to Router Function -This setup allows you to test <> via +This setup allows you to test xref:web/webflux-functional.adoc[functional endpoints] via mock request and response objects, without a running server. For WebFlux, use the following which delegates to `RouterFunctions.toWebHandler` to @@ -203,7 +203,7 @@ create a server setup to handle requests: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RouterFunction route = ... client = WebTestClient.bindToRouterFunction(route).build(); @@ -211,7 +211,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val route: RouterFunction<*> = ... val client = WebTestClient.bindToRouterFunction(route).build() @@ -232,14 +232,14 @@ This setup connects to a running server to perform full, end-to-end HTTP tests: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- client = WebTestClient.bindToServer().baseUrl("http://localhost:8080").build(); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- client = WebTestClient.bindToServer().baseUrl("http://localhost:8080").build() ---- @@ -260,7 +260,7 @@ follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- client = WebTestClient.bindToController(new TestController()) .configureClient() @@ -270,7 +270,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- client = WebTestClient.bindToController(TestController()) .configureClient() @@ -299,7 +299,7 @@ To assert the response status and headers, use the following: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- client.get().uri("/persons/1") .accept(MediaType.APPLICATION_JSON) @@ -310,7 +310,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- client.get().uri("/persons/1") .accept(MediaType.APPLICATION_JSON) @@ -329,7 +329,7 @@ JUnit Jupiter. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- client.get().uri("/persons/1") .accept(MediaType.APPLICATION_JSON) @@ -342,7 +342,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- client.get().uri("/persons/1") .accept(MediaType.APPLICATION_JSON) @@ -366,7 +366,7 @@ And perform assertions on the resulting higher level Object(s): ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- client.get().uri("/persons") .exchange() @@ -376,7 +376,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.test.web.reactive.server.expectBodyList @@ -394,7 +394,7 @@ perform any other assertions: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import org.springframework.test.web.reactive.server.expectBody @@ -403,20 +403,20 @@ Java:: .expectStatus().isOk() .expectBody(Person.class) .consumeWith(result -> { - // custom assertions (e.g. AssertJ)... + // custom assertions (for example, AssertJ)... }); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- client.get().uri("/persons/1") .exchange() .expectStatus().isOk() .expectBody() .consumeWith { - // custom assertions (e.g. AssertJ)... + // custom assertions (for example, AssertJ)... } ---- ====== @@ -427,7 +427,7 @@ Or you can exit the workflow and obtain an `EntityExchangeResult`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- EntityExchangeResult result = client.get().uri("/persons/1") .exchange() @@ -438,7 +438,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.test.web.reactive.server.expectBody @@ -466,7 +466,7 @@ If the response is not expected to have content, you can assert that as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- client.post().uri("/persons") .body(personMono, Person.class) @@ -477,7 +477,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- client.post().uri("/persons") .bodyValue(person) @@ -494,7 +494,7 @@ any assertions: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- client.get().uri("/persons/123") .exchange() @@ -504,7 +504,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- client.get().uri("/persons/123") .exchange() @@ -527,7 +527,7 @@ To verify the full JSON content with https://jsonassert.skyscreamer.org[JSONAsse ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- client.get().uri("/persons/1") .exchange() @@ -538,7 +538,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- client.get().uri("/persons/1") .exchange() @@ -554,7 +554,7 @@ To verify JSON content with https://github.com/jayway/JsonPath[JSONPath]: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- client.get().uri("/persons") .exchange() @@ -566,7 +566,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- client.get().uri("/persons") .exchange() @@ -590,7 +590,7 @@ obtain a `FluxExchangeResult`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- FluxExchangeResult result = client.get().uri("/events") .accept(TEXT_EVENT_STREAM) @@ -602,7 +602,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.test.web.reactive.server.returnResult @@ -620,7 +620,7 @@ Now you're ready to consume the response stream with `StepVerifier` from `reacto ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Flux eventFlux = result.getResponseBody(); @@ -634,7 +634,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val eventFlux = result.getResponseBody() @@ -662,7 +662,7 @@ obtaining an `ExchangeResult` after asserting the body: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // For a response with a body EntityExchangeResult result = client.get().uri("/persons/1") @@ -679,7 +679,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // For a response with a body val result = client.get().uri("/persons/1") @@ -701,7 +701,7 @@ Then switch to MockMvc server response assertions: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- MockMvcWebTestClient.resultActionsFor(result) .andExpect(model().attribute("integer", 3)) @@ -710,7 +710,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- MockMvcWebTestClient.resultActionsFor(result) .andExpect(model().attribute("integer", 3)) diff --git a/framework-docs/modules/ROOT/pages/web/integration.adoc b/framework-docs/modules/ROOT/pages/web/integration.adoc index 55276ff2bcb2..ed280eb57b1d 100644 --- a/framework-docs/modules/ROOT/pages/web/integration.adoc +++ b/framework-docs/modules/ROOT/pages/web/integration.adoc @@ -103,7 +103,7 @@ on its specific integration strategies. JavaServer Faces (JSF) is the JCP's standard component-based, event-driven web user interface framework. It is an official part of the Jakarta EE umbrella but also -individually usable, e.g. through embedding Mojarra or MyFaces within Tomcat. +individually usable, for example, through embedding Mojarra or MyFaces within Tomcat. Please note that recent versions of JSF became closely tied to CDI infrastructure in application servers, with some new JSF functionality only working in such an diff --git a/framework-docs/modules/ROOT/pages/web/webflux-cors.adoc b/framework-docs/modules/ROOT/pages/web/webflux-cors.adoc index 4de277efa7ea..5553837d8a06 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-cors.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-cors.adoc @@ -1,5 +1,6 @@ [[webflux-cors]] = CORS + [.small]#xref:web/webmvc-cors.adoc[See equivalent in the Servlet stack]# Spring WebFlux lets you handle CORS (Cross-Origin Resource Sharing). This section @@ -117,7 +118,7 @@ following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RestController @RequestMapping("/account") @@ -138,7 +139,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RestController @RequestMapping("/account") @@ -181,7 +182,7 @@ The following example specifies a certain domain and sets `maxAge` to an hour: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @CrossOrigin(origins = "https://domain2.com", maxAge = 3600) @RestController @@ -202,7 +203,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @CrossOrigin("https://domain2.com", maxAge = 3600) @RestController @@ -231,7 +232,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @CrossOrigin(maxAge = 3600) // <1> @RestController @@ -255,7 +256,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @CrossOrigin(maxAge = 3600) // <1> @RestController @@ -311,7 +312,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -334,7 +335,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -364,7 +365,7 @@ Kotlin:: You can apply CORS support through the built-in {spring-framework-api}/web/cors/reactive/CorsWebFilter.html[`CorsWebFilter`], which is a -good fit with <>. +good fit with xref:web/webflux-functional.adoc[functional endpoints]. NOTE: If you try to use the `CorsFilter` with Spring Security, keep in mind that Spring Security has {docs-spring-security}/servlet/integrations/cors.html[built-in support] for @@ -377,7 +378,7 @@ To configure the filter, you can declare a `CorsWebFilter` bean and pass a ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Bean CorsWebFilter corsFilter() { @@ -401,7 +402,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Bean fun corsFilter(): CorsWebFilter { diff --git a/framework-docs/modules/ROOT/pages/web/webflux-functional.adoc b/framework-docs/modules/ROOT/pages/web/webflux-functional.adoc index 5f03e5a131ea..f0aaea966b55 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-functional.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-functional.adoc @@ -1,5 +1,6 @@ [[webflux-fn]] = Functional Endpoints + [.small]#xref:web/webmvc-functional.adoc[See equivalent in the Servlet stack]# Spring WebFlux includes WebFlux.fn, a lightweight functional programming model in which functions @@ -34,7 +35,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.web.reactive.function.server.RequestPredicates.*; @@ -71,7 +72,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val repository: PersonRepository = ... val handler = PersonHandler(repository) @@ -142,14 +143,14 @@ The following example extracts the request body to a `Mono`: ====== Java:: + -[source,java,role="primary"] +[source,java] ---- Mono string = request.bodyToMono(String.class); ---- Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- val string = request.awaitBody() ---- @@ -163,14 +164,14 @@ where `Person` objects are decoded from some serialized form, such as JSON or XM ====== Java:: + -[source,java,role="primary"] +[source,java] ---- Flux people = request.bodyToFlux(Person.class); ---- Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- val people = request.bodyToFlow() ---- @@ -185,7 +186,7 @@ also be written as follows: ====== Java:: + -[source,java,role="primary"] +[source,java] ---- Mono string = request.body(BodyExtractors.toMono(String.class)); Flux people = request.body(BodyExtractors.toFlux(Person.class)); @@ -193,7 +194,7 @@ Flux people = request.body(BodyExtractors.toFlux(Person.class)); Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- val string = request.body(BodyExtractors.toMono(String::class.java)).awaitSingle() val people = request.body(BodyExtractors.toFlux(Person::class.java)).asFlow() @@ -206,14 +207,14 @@ The following example shows how to access form data: ====== Java:: + -[source,java,role="primary"] +[source,java] ---- Mono> map = request.formData(); ---- Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- val map = request.awaitFormData() ---- @@ -225,14 +226,14 @@ The following example shows how to access multipart data as a map: ====== Java:: + -[source,java,role="primary"] +[source,java] ---- Mono> map = request.multipartData(); ---- Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- val map = request.awaitMultipartData() ---- @@ -244,7 +245,7 @@ The following example shows how to access multipart data, one at a time, in stre ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Flux allPartEvents = request.bodyToFlux(PartEvent.class); allPartsEvents.windowUntil(PartEvent::isLast) @@ -272,7 +273,7 @@ allPartsEvents.windowUntil(PartEvent::isLast) Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val parts = request.bodyToFlux() allPartsEvents.windowUntil(PartEvent::isLast) @@ -313,7 +314,7 @@ content: ====== Java:: + -[source,java,role="primary"] +[source,java] ---- Mono person = ... ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(person, Person.class); @@ -321,7 +322,7 @@ ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(person, Person. Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- val person: Person = ... ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).bodyValue(person) @@ -334,7 +335,7 @@ The following example shows how to build a 201 (CREATED) response with a `Locati ====== Java:: + -[source,java,role="primary"] +[source,java] ---- URI location = ... ServerResponse.created(location).build(); @@ -342,7 +343,7 @@ ServerResponse.created(location).build(); Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- val location: URI = ... ServerResponse.created(location).build() @@ -356,14 +357,14 @@ body is serialized or deserialized. For example, to specify a {baeldung-blog}/ja ====== Java:: + -[source,java,role="primary"] +[source,java] ---- ServerResponse.ok().hint(Jackson2CodecSupport.JSON_VIEW_HINT, MyJacksonView.class).body(...); ---- Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- ServerResponse.ok().hint(Jackson2CodecSupport.JSON_VIEW_HINT, MyJacksonView::class.java).body(...) ---- @@ -380,7 +381,7 @@ We can write a handler function as a lambda, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HandlerFunction helloWorld = request -> ServerResponse.ok().bodyValue("Hello World"); @@ -388,7 +389,7 @@ HandlerFunction helloWorld = Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val helloWorld = HandlerFunction { ServerResponse.ok().bodyValue("Hello World") } ---- @@ -406,7 +407,7 @@ For example, the following class exposes a reactive `Person` repository: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.web.reactive.function.server.ServerResponse.ok; @@ -450,7 +451,7 @@ found. If it is not found, we use `switchIfEmpty(Mono)` to return a 404 Not F Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class PersonHandler(private val repository: PersonRepository) { @@ -495,7 +496,7 @@ xref:web/webmvc/mvc-config/validation.adoc[Validator] implementation for a `Pers ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class PersonHandler { @@ -523,7 +524,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class PersonHandler(private val repository: PersonRepository) { @@ -593,7 +594,7 @@ header: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RouterFunction route = RouterFunctions.route() .GET("/hello-world", accept(MediaType.TEXT_PLAIN), @@ -602,7 +603,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val route = coRouter { GET("/hello-world", accept(TEXT_PLAIN)) { @@ -652,7 +653,7 @@ The following example shows the composition of four routes: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.web.reactive.function.server.RequestPredicates.*; @@ -679,7 +680,7 @@ RouterFunction route = route() Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.http.MediaType.APPLICATION_JSON @@ -719,7 +720,7 @@ improved in the following way by using nested routes: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RouterFunction route = route() .path("/person", builder -> builder // <1> @@ -732,7 +733,7 @@ RouterFunction route = route() Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val route = coRouter { // <1> "/person".nest { @@ -754,7 +755,7 @@ We can further improve by using the `nest` method together with `accept`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RouterFunction route = route() .path("/person", b1 -> b1 @@ -767,7 +768,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val route = coRouter { "/person".nest { @@ -800,10 +801,10 @@ for handling redirects in Single Page Applications. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ClassPathResource index = new ClassPathResource("static/index.html"); - List extensions = Arrays.asList("js", "css", "ico", "png", "jpg", "gif"); + List extensions = List.of("js", "css", "ico", "png", "jpg", "gif"); RequestPredicate spaPredicate = path("/api/**").or(path("/error")).or(pathExtension(extensions::contains)).negate(); RouterFunction redirectToIndex = route() .resource(spaPredicate, index) @@ -812,7 +813,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val redirectToIndex = router { val index = ClassPathResource("static/index.html") @@ -833,17 +834,17 @@ It is also possible to route requests that match a given pattern to resources re ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- - Resource location = new FileSystemResource("public-resources/"); + Resource location = new FileUrlResource("public-resources/"); RouterFunction resources = RouterFunctions.resources("/resources/**", location); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- - val location = FileSystemResource("public-resources/") + val location = FileUrlResource("public-resources/") val resources = router { resources("/resources/**", location) } ---- ====== @@ -888,7 +889,7 @@ xref:web/webflux/dispatcher-handler.adoc[DispatcherHandler] for how to run it): ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -925,7 +926,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -976,7 +977,7 @@ For instance, consider the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RouterFunction route = route() .path("/person", b1 -> b1 @@ -995,7 +996,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val route = router { "/person".nest { @@ -1031,7 +1032,7 @@ The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- SecurityManager securityManager = ... @@ -1054,7 +1055,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val securityManager: SecurityManager = ... diff --git a/framework-docs/modules/ROOT/pages/web/webflux-view.adoc b/framework-docs/modules/ROOT/pages/web/webflux-view.adoc index 288fc1a38c52..b37208c527ae 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-view.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-view.adoc @@ -2,10 +2,17 @@ = View Technologies [.small]#xref:web/webmvc-view.adoc[See equivalent in the Servlet stack]# -The use of view technologies in Spring WebFlux is pluggable. Whether you decide to +The rendering of views in Spring WebFlux is pluggable. Whether you decide to use Thymeleaf, FreeMarker, or some other view technology is primarily a matter of a configuration change. This chapter covers the view technologies integrated with Spring -WebFlux. We assume you are already familiar with xref:web/webflux/dispatcher-handler.adoc#webflux-viewresolution[View Resolution]. +WebFlux. + +For more context on view rendering, please see xref:web/webflux/dispatcher-handler.adoc#webflux-viewresolution[View Resolution]. + +WARNING: The views of a Spring WebFlux application live within internal trust boundaries +of the application. Views have access to beans in the application context, and as +such, we do not recommend use the Spring WebFlux template support in applications where +the templates are editable by external sources, since this can have security implications. @@ -51,7 +58,7 @@ The following example shows how to configure FreeMarker as a view technology: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -75,7 +82,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -116,7 +123,7 @@ a `java.util.Properties` object, and the `freemarkerVariables` property requires ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -139,7 +146,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -261,7 +268,7 @@ The following example uses Mustache templates and the Nashorn JavaScript engine: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -286,7 +293,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -329,7 +336,7 @@ The following example shows how to set a custom render function: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -354,7 +361,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -411,6 +418,81 @@ for more configuration examples. +[[webflux-view-fragments]] +== HTML Fragment +[.small]#xref:web/webmvc-view/mvc-fragments.adoc[See equivalent in the Servlet stack]# + +https://htmx.org/[HTMX] and https://turbo.hotwired.dev/[Hotwire Turbo] emphasize an +HTML-over-the-wire approach where clients receive server updates in HTML rather than in JSON. +This allows the benefits of an SPA (single page app) without having to write much or even +any JavaScript. For a good overview and to learn more, please visit their respective +websites. + +In Spring WebFlux, view rendering typically involves specifying one view and one model. +However, in HTML-over-the-wire a common capability is to send multiple HTML fragments that +the browser can use to update different parts of the page. For this, controller methods +can return `Collection`. For example: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @GetMapping + List handle() { + return List.of(Fragment.create("posts"), Fragment.create("comments")); + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @GetMapping + fun handle(): List { + return listOf(Fragment.create("posts"), Fragment.create("comments")) + } +---- +====== + +The same can be done also by returning the dedicated type `FragmentsRendering`: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @GetMapping + FragmentsRendering handle() { + return FragmentsRendering.with("posts").fragment("comments").build(); + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @GetMapping + fun handle(): FragmentsRendering { + return FragmentsRendering.with("posts").fragment("comments").build() + } +---- +====== + +Each fragment can have an independent model, and that model inherits attributes from the +shared model for the request. + +HTMX and Hotwire Turbo support streaming updates over SSE (server-sent events). +A controller can create `FragmentsRendering` with a `Flux`, or with any other +reactive producer adaptable to a Reactive Streams `Publisher` via `ReactiveAdapterRegistry`. +It is also possible to return `Flux` directly without the `FragmentsRendering` +wrapper. + + + + [[webflux-view-httpmessagewriter]] == JSON and XML [.small]#xref:web/webmvc-view/mvc-jackson.adoc[See equivalent in the Servlet stack]# diff --git a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-attributes.adoc b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-attributes.adoc index 1683021b269b..df6e89419a6d 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-attributes.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-attributes.adoc @@ -9,7 +9,7 @@ For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebClient client = WebClient.builder() .filter((request, next) -> { @@ -28,7 +28,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val client = WebClient.builder() .filter { request, _ -> diff --git a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-body.adoc b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-body.adoc index 4419eaa296fe..bd52881b4591 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-body.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-body.adoc @@ -8,7 +8,7 @@ like `Mono` or Kotlin Coroutines `Deferred` as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Mono personMono = ... ; @@ -22,7 +22,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val personDeferred: Deferred = ... @@ -41,7 +41,7 @@ You can also have a stream of objects be encoded, as the following example shows ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Flux personFlux = ... ; @@ -55,7 +55,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val people: Flow = ... @@ -75,7 +75,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Person person = ... ; @@ -89,7 +89,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val person: Person = ... @@ -115,7 +115,7 @@ content is automatically set to `application/x-www-form-urlencoded` by the ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- MultiValueMap formData = ... ; @@ -128,7 +128,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val formData: MultiValueMap = ... @@ -146,7 +146,7 @@ You can also supply form data in-line by using `BodyInserters`, as the following ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import static org.springframework.web.reactive.function.BodyInserters.*; @@ -159,7 +159,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.web.reactive.function.BodyInserters.* @@ -185,7 +185,7 @@ multipart request. The following example shows how to create a `MultiValueMap result = webClient @@ -320,7 +320,7 @@ Mono result = webClient Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- var resource: Resource = ... var result: Mono = webClient diff --git a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-builder.adoc b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-builder.adoc index 3d326fadfd32..53a2fc247cb8 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-builder.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-builder.adoc @@ -25,7 +25,7 @@ For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebClient client = WebClient.builder() .codecs(configurer -> ... ) @@ -34,7 +34,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val webClient = WebClient.builder() .codecs { configurer -> ... } @@ -49,7 +49,7 @@ modified copy as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebClient client1 = WebClient.builder() .filter(filterA).filter(filterB).build(); @@ -64,7 +64,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val client1 = WebClient.builder() .filter(filterA).filter(filterB).build() @@ -95,7 +95,7 @@ To change the limit for default codecs, use the following: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebClient webClient = WebClient.builder() .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(2 * 1024 * 1024)) @@ -104,7 +104,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val webClient = WebClient.builder() .codecs { configurer -> configurer.defaultCodecs().maxInMemorySize(2 * 1024 * 1024) } @@ -123,7 +123,7 @@ To customize Reactor Netty settings, provide a pre-configured `HttpClient`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HttpClient httpClient = HttpClient.create().secure(sslSpec -> ...); @@ -134,7 +134,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val httpClient = HttpClient.create().secure { ... } @@ -165,7 +165,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Bean public ReactorResourceFactory reactorResourceFactory() { @@ -175,7 +175,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Bean fun reactorResourceFactory() = ReactorResourceFactory() @@ -192,7 +192,7 @@ instances use shared resources, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Bean public ReactorResourceFactory resourceFactory() { @@ -220,7 +220,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Bean fun resourceFactory() = ReactorResourceFactory().apply { @@ -255,7 +255,7 @@ To configure a connection timeout: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import io.netty.channel.ChannelOption; @@ -269,7 +269,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import io.netty.channel.ChannelOption @@ -288,7 +288,7 @@ To configure a read or write timeout: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import io.netty.handler.timeout.ReadTimeoutHandler; import io.netty.handler.timeout.WriteTimeoutHandler; @@ -304,7 +304,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import io.netty.handler.timeout.ReadTimeoutHandler import io.netty.handler.timeout.WriteTimeoutHandler @@ -325,7 +325,7 @@ To configure a response timeout for all requests: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HttpClient httpClient = HttpClient.create() .responseTimeout(Duration.ofSeconds(2)); @@ -335,7 +335,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val httpClient = HttpClient.create() .responseTimeout(Duration.ofSeconds(2)); @@ -350,7 +350,7 @@ To configure a response timeout for a specific request: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebClient.create().get() .uri("https://example.org/path") @@ -364,7 +364,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- WebClient.create().get() .uri("https://example.org/path") @@ -388,7 +388,7 @@ The following example shows how to customize the JDK `HttpClient`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HttpClient httpClient = HttpClient.newBuilder() .followRedirects(Redirect.NORMAL) @@ -403,7 +403,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val httpClient = HttpClient.newBuilder() .followRedirects(Redirect.NORMAL) @@ -428,7 +428,7 @@ The following example shows how to customize Jetty `HttpClient` settings: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HttpClient httpClient = new HttpClient(); httpClient.setCookieStore(...); @@ -440,7 +440,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val httpClient = HttpClient() httpClient.cookieStore = ... @@ -465,7 +465,7 @@ shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Bean public JettyResourceFactory resourceFactory() { @@ -489,7 +489,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Bean fun resourceFactory() = JettyResourceFactory() @@ -521,7 +521,7 @@ The following example shows how to customize Apache HttpComponents `HttpClient` ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HttpAsyncClientBuilder clientBuilder = HttpAsyncClients.custom(); clientBuilder.setDefaultRequestConfig(...); @@ -534,7 +534,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val client = HttpAsyncClients.custom().apply { setDefaultRequestConfig(...) diff --git a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-context.adoc b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-context.adoc index 749517ae205a..eb2619849e4a 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-context.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-context.adoc @@ -3,8 +3,8 @@ xref:web/webflux-webclient/client-attributes.adoc[Attributes] provide a convenient way to pass information to the filter chain but they only influence the current request. If you want to pass information that -propagates to additional requests that are nested, e.g. via `flatMap`, or executed after, -e.g. via `concatMap`, then you'll need to use the Reactor `Context`. +propagates to additional requests that are nested, for example, via `flatMap`, or executed after, +for example, via `concatMap`, then you'll need to use the Reactor `Context`. The Reactor `Context` needs to be populated at the end of a reactive chain in order to apply to all operations. For example: @@ -13,7 +13,7 @@ apply to all operations. For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebClient client = WebClient.builder() .filter((request, next) -> diff --git a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-exchange.adoc b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-exchange.adoc index 83ddb7f3a88d..fa14363330f7 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-exchange.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-exchange.adoc @@ -9,7 +9,7 @@ depending on the response status: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Mono entityMono = client.get() .uri("/persons/1") @@ -27,7 +27,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val entity = client.get() .uri("/persons/1") diff --git a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-filter.adoc b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-filter.adoc index c1bd622687c0..d63ed06a9fb8 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-filter.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-filter.adoc @@ -8,7 +8,7 @@ in order to intercept and modify requests, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebClient client = WebClient.builder() .filter((request, next) -> { @@ -24,7 +24,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val client = WebClient.builder() .filter { request, next -> @@ -46,7 +46,7 @@ a filter for basic authentication through a static factory method: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import static org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication; @@ -57,7 +57,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication @@ -74,7 +74,7 @@ in a new `WebClient` instance that does not affect the original one. For example ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import static org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication; @@ -87,7 +87,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val client = webClient.mutate() .filters { it.add(0, basicAuthentication("user", "password")) } @@ -107,7 +107,7 @@ any response content, whether expected or not, is released: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public ExchangeFilterFunction renewTokenFilter() { return (request, next) -> next.exchange(request).flatMap(response -> { @@ -127,7 +127,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun renewTokenFilter(): ExchangeFilterFunction? { return ExchangeFilterFunction { request: ClientRequest?, next: ExchangeFunction -> @@ -156,7 +156,7 @@ a custom filter class that helps with computing a `Content-Length` header for `P ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MultipartExchangeFilterFunction implements ExchangeFilterFunction { @@ -191,7 +191,7 @@ public class MultipartExchangeFilterFunction implements ExchangeFilterFunction { Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MultipartExchangeFilterFunction : ExchangeFilterFunction { diff --git a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-retrieve.adoc b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-retrieve.adoc index 75e0000bcc24..256b8cc934c7 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-retrieve.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-retrieve.adoc @@ -7,7 +7,7 @@ The `retrieve()` method can be used to declare how to extract the response. For ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebClient client = WebClient.create("https://example.org"); @@ -19,7 +19,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val client = WebClient.create("https://example.org") @@ -36,7 +36,7 @@ Or to get only the body: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebClient client = WebClient.create("https://example.org"); @@ -48,7 +48,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val client = WebClient.create("https://example.org") @@ -65,7 +65,7 @@ To get a stream of decoded objects: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Flux result = client.get() .uri("/quotes").accept(MediaType.TEXT_EVENT_STREAM) @@ -75,7 +75,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val result = client.get() .uri("/quotes").accept(MediaType.TEXT_EVENT_STREAM) @@ -92,7 +92,7 @@ responses, use `onStatus` handlers as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Mono result = client.get() .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON) @@ -104,7 +104,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val result = client.get() .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON) diff --git a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-synchronous.adoc b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-synchronous.adoc index 7f9c4a0e4ffc..dcc2ddc5ae01 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-synchronous.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-webclient/client-synchronous.adoc @@ -7,7 +7,7 @@ ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Person person = client.get().uri("/person/{id}", i).retrieve() .bodyToMono(Person.class) @@ -21,7 +21,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val person = runBlocking { client.get().uri("/person/{id}", i).retrieve() @@ -43,7 +43,7 @@ response individually, and instead wait for the combined result: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Mono personMono = client.get().uri("/person/{id}", personId) .retrieve().bodyToMono(Person.class); @@ -62,7 +62,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val data = runBlocking { val personDeferred = async { diff --git a/framework-docs/modules/ROOT/pages/web/webflux-websocket.adoc b/framework-docs/modules/ROOT/pages/web/webflux-websocket.adoc index 204c5f771fe8..1e7f397c19b6 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-websocket.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-websocket.adoc @@ -1,5 +1,6 @@ [[webflux-websocket]] = WebSockets + [.small]#xref:web/websocket.adoc[See equivalent in the Servlet stack]# This part of the reference documentation covers support for reactive-stack WebSocket @@ -27,7 +28,7 @@ The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import org.springframework.web.reactive.socket.WebSocketHandler; import org.springframework.web.reactive.socket.WebSocketSession; @@ -43,7 +44,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.web.reactive.socket.WebSocketHandler import org.springframework.web.reactive.socket.WebSocketSession @@ -63,7 +64,7 @@ Then you can map it to a URL: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration class WebConfig { @@ -81,7 +82,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class WebConfig { @@ -105,7 +106,7 @@ further to do, or otherwise if not using the WebFlux config you'll need to decla ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration class WebConfig { @@ -121,7 +122,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class WebConfig { @@ -177,7 +178,7 @@ following example shows such an implementation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- class ExampleHandler implements WebSocketHandler { @@ -201,7 +202,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ExampleHandler : WebSocketHandler { @@ -235,7 +236,7 @@ The following implementation combines the inbound and outbound streams: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- class ExampleHandler implements WebSocketHandler { @@ -261,7 +262,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ExampleHandler : WebSocketHandler { @@ -293,7 +294,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- class ExampleHandler implements WebSocketHandler { @@ -322,7 +323,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class ExampleHandler : WebSocketHandler { @@ -396,7 +397,7 @@ not using the WebFlux config, use the below: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration class WebConfig { @@ -417,7 +418,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class WebConfig { @@ -472,7 +473,7 @@ methods: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebSocketClient client = new ReactorNettyWebSocketClient(); @@ -485,7 +486,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val client = ReactorNettyWebSocketClient() diff --git a/framework-docs/modules/ROOT/pages/web/webflux/ann-rest-exceptions.adoc b/framework-docs/modules/ROOT/pages/web/webflux/ann-rest-exceptions.adoc index 39c5ac92357c..c3481a3e5d11 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/ann-rest-exceptions.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/ann-rest-exceptions.adoc @@ -44,6 +44,10 @@ has an `@ExceptionHandler` method that handles any `ErrorResponse` exception, wh includes all built-in web exceptions. You can add more exception handling methods, and use a protected method to map any exception to a `ProblemDetail`. +You can register `ErrorResponse` interceptors through the +xref:web/webflux/config.adoc[WebFlux Config] with a `WebFluxConfigurer`. Use that to intercept +any RFC 9457 response and take some action. + [[webflux-ann-rest-exceptions-non-standard]] @@ -60,7 +64,7 @@ this `Map`. You can also extend `ProblemDetail` to add dedicated non-standard properties. The copy constructor in `ProblemDetail` allows a subclass to make it easy to be created -from an existing `ProblemDetail`. This could be done centrally, e.g. from an +from an existing `ProblemDetail`. This could be done centrally, for example, from an `@ControllerAdvice` such as `ResponseEntityExceptionHandler` that re-creates the `ProblemDetail` of an exception into a subclass with the additional non-standard fields. @@ -103,7 +107,7 @@ Message codes and arguments for each error are also resolved via `MessageSource` | `MissingRequestValueException` | (default) -| `+{0}+` a label for the value (e.g. "request header", "cookie value", ...), `+{1}+` the value name +| `+{0}+` a label for the value (for example, "request header", "cookie value", ...), `+{1}+` the value name | `NotAcceptableStatusException` | (default) diff --git a/framework-docs/modules/ROOT/pages/web/webflux/caching.adoc b/framework-docs/modules/ROOT/pages/web/webflux/caching.adoc index a01e743b5edf..6fe3f94de44c 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/caching.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/caching.adoc @@ -34,7 +34,7 @@ use case-oriented approach that focuses on the common scenarios, as the followin ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // Cache for an hour - "Cache-Control: max-age=3600" CacheControl ccCacheOneHour = CacheControl.maxAge(1, TimeUnit.HOURS); @@ -50,7 +50,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Cache for an hour - "Cache-Control: max-age=3600" val ccCacheOneHour = CacheControl.maxAge(1, TimeUnit.HOURS) @@ -82,7 +82,7 @@ settings to a `ResponseEntity`, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/book/{id}") public ResponseEntity showBook(@PathVariable Long id) { @@ -100,7 +100,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/book/{id}") fun showBook(@PathVariable id: Long): ResponseEntity { @@ -130,7 +130,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RequestMapping public String myHandleMethod(ServerWebExchange exchange, Model model) { @@ -151,7 +151,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RequestMapping fun myHandleMethod(exchange: ServerWebExchange, model: Model): String? { diff --git a/framework-docs/modules/ROOT/pages/web/webflux/config.adoc b/framework-docs/modules/ROOT/pages/web/webflux/config.adoc index 2e7438b2d8d3..15f15e7115db 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/config.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/config.adoc @@ -26,7 +26,7 @@ You can use the `@EnableWebFlux` annotation in your Java config, as the followin ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -36,7 +36,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -44,6 +44,11 @@ Kotlin:: ---- ====== +NOTE: When using Spring Boot, you may want to use `@Configuration` classes of type `WebFluxConfigurer` but without +`@EnableWebFlux` to keep Spring Boot WebFlux customizations. See more details in +xref:#webflux-config-customize[the WebFlux config API section] and in +{spring-boot-docs-ref}/web/reactive.html#web.reactive.webflux.auto-configuration[the dedicated Spring Boot documentation]. + The preceding example registers a number of Spring WebFlux xref:web/webflux/dispatcher-handler.adoc#webflux-special-bean-types[infrastructure beans] and adapts to dependencies available on the classpath -- for JSON, XML, and others. @@ -61,10 +66,9 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { // Implement configuration methods... @@ -73,10 +77,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration -@EnableWebFlux class WebConfig : WebFluxConfigurer { // Implement configuration methods... @@ -91,7 +94,8 @@ class WebConfig : WebFluxConfigurer { [.small]#xref:web/webmvc/mvc-config/conversion.adoc[See equivalent in the Servlet stack]# By default, formatters for various number and date types are installed, along with support -for customization via `@NumberFormat` and `@DateTimeFormat` on fields and parameters. +for customization via `@NumberFormat`, `@DurationFormat`, and `@DateTimeFormat` on fields +and parameters. To register custom formatters and converters in Java config, use the following: @@ -99,10 +103,9 @@ To register custom formatters and converters in Java config, use the following: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { @Override @@ -115,10 +118,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux class WebConfig : WebFluxConfigurer { override fun addFormatters(registry: FormatterRegistry) { @@ -137,10 +139,9 @@ in the HTML spec. For such cases date and time formatting can be customized as f ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { @Override @@ -154,10 +155,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux class WebConfig : WebFluxConfigurer { override fun addFormatters(registry: FormatterRegistry) { @@ -191,10 +191,9 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { @Override @@ -207,10 +206,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux class WebConfig : WebFluxConfigurer { override fun getValidator(): Validator { @@ -228,7 +226,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller public class MyController { @@ -243,7 +241,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller class MyController { @@ -276,10 +274,9 @@ The following example shows how to customize the requested content type resoluti ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { @Override @@ -291,10 +288,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux class WebConfig : WebFluxConfigurer { override fun configureContentTypeResolver(builder: RequestedContentTypeResolverBuilder) { @@ -316,10 +312,9 @@ The following example shows how to customize how the request and response body a ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { @Override @@ -331,10 +326,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux class WebConfig : WebFluxConfigurer { override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) { @@ -372,10 +366,9 @@ The following example shows how to configure view resolution: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { @Override @@ -387,10 +380,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux class WebConfig : WebFluxConfigurer { override fun configureViewResolvers(registry: ViewResolverRegistry) { @@ -408,10 +400,9 @@ underlying FreeMarker view technology): ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { @@ -433,10 +424,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux class WebConfig : WebFluxConfigurer { override fun configureViewResolvers(registry: ViewResolverRegistry) { @@ -459,10 +449,9 @@ You can also plug in any `ViewResolver` implementation, as the following example ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { @@ -476,10 +465,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux class WebConfig : WebFluxConfigurer { override fun configureViewResolvers(registry: ViewResolverRegistry) { @@ -499,10 +487,9 @@ xref:web/webflux/reactive-spring.adoc#webflux-codecs[Codecs] from `spring-web`. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { @@ -520,10 +507,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux class WebConfig : WebFluxConfigurer { @@ -561,10 +547,9 @@ the example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { @Override @@ -579,10 +564,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Configuration - @EnableWebFlux class WebConfig : WebFluxConfigurer { override fun addResourceHandlers(registry: ResourceHandlerRegistry) { @@ -612,10 +596,9 @@ The following example shows how to use `VersionResourceResolver` in your Java co ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { @Override @@ -631,10 +614,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- @Configuration - @EnableWebFlux class WebConfig : WebFluxConfigurer { override fun addResourceHandlers(registry: ResourceHandlerRegistry) { @@ -675,7 +657,7 @@ include the version of the jar and can also match against incoming URLs without -- for example, from `/webjars/jquery/jquery.min.js` to `/webjars/jquery/1.2.0/jquery.min.js`. TIP: The Java configuration based on `ResourceHandlerRegistry` provides further options -for fine-grained control, e.g. last-modified behavior and optimized resource resolution. +for fine-grained control, for example, last-modified behavior and optimized resource resolution. @@ -719,10 +701,9 @@ as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { @Override @@ -735,10 +716,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux class WebConfig : WebFluxConfigurer { @Override @@ -773,10 +753,9 @@ For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux public class WebConfig implements WebFluxConfigurer { @Override @@ -790,10 +769,9 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration - @EnableWebFlux class WebConfig : WebFluxConfigurer { @Override @@ -828,7 +806,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class WebConfig extends DelegatingWebFluxConfiguration { @@ -839,7 +817,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class WebConfig : DelegatingWebFluxConfiguration { diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller.adoc index 5db87830f5a5..14bbd8268200 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller.adoc @@ -14,7 +14,7 @@ The following listing shows a basic example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RestController public class HelloController { @@ -28,7 +28,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RestController class HelloController { diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-advice.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-advice.adoc index cf77c0ca8409..7f6f64be3502 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-advice.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-advice.adoc @@ -29,7 +29,7 @@ annotation, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // Target all Controllers annotated with @RestController @ControllerAdvice(annotations = RestController.class) @@ -46,7 +46,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Target all Controllers annotated with @RestController @ControllerAdvice(annotations = [RestController::class]) diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-exceptions.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-exceptions.adoc index c62341d365a7..a4435e11d5f5 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-exceptions.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-exceptions.adoc @@ -7,43 +7,8 @@ `@ExceptionHandler` methods to handle exceptions from controller methods. The following example includes such a handler method: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Controller - public class SimpleController { - - // ... - - @ExceptionHandler // <1> - public ResponseEntity handle(IOException ex) { - // ... - } - } ----- -<1> Declaring an `@ExceptionHandler`. - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Controller - class SimpleController { - - // ... - - @ExceptionHandler // <1> - fun handle(ex: IOException): ResponseEntity { - // ... - } - } ----- -<1> Declaring an `@ExceptionHandler`. -====== +include-code::./SimpleController[indent=0] The exception can match against a top-level exception being propagated (that is, a direct @@ -65,6 +30,22 @@ Support for `@ExceptionHandler` methods in Spring WebFlux is provided by the `HandlerAdapter` for `@RequestMapping` methods. See xref:web/webflux/dispatcher-handler.adoc[`DispatcherHandler`] for more detail. +[[webflux-ann-exceptionhandler-media]] +== Media Type Mapping +[.small]#xref:web/webmvc/mvc-controller/ann-exceptionhandler.adoc#mvc-ann-exceptionhandler-media[See equivalent in the Servlet stack]# + +In addition to exception types, `@ExceptionHandler` methods can also declare producible media types. +This allows to refine error responses depending on the media types requested by HTTP clients, typically in the "Accept" HTTP request header. + +Applications can declare producible media types directly on annotations, for the same exception type: + + +include-code::./MediaTypeController[tag=mediatype,indent=0] + +Here, methods handle the same exception type but will not be rejected as duplicates. +Instead, API clients requesting "application/json" will receive a JSON error, and browsers will get an HTML error view. +Each `@ExceptionHandler` annotation can declare several producible media types, +the content negotiation during the error handling phase will decide which content type will be used. [[webflux-ann-exceptionhandler-args]] diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-initbinder.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-initbinder.adoc index 3893881ea42d..d093ba9e3de9 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-initbinder.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-initbinder.adoc @@ -24,7 +24,7 @@ xref:web/webflux/config.adoc#webflux-config-conversion[WebFlux config] to regist ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller public class FormController { @@ -43,7 +43,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller class FormController { @@ -71,7 +71,7 @@ controller-specific `Formatter` instances, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller public class FormController { @@ -88,7 +88,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller class FormController { diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/cookievalue.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/cookievalue.adoc index 79b62352e7b7..49074543f082 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/cookievalue.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/cookievalue.adoc @@ -19,7 +19,7 @@ The following code sample demonstrates how to get the cookie value: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/demo") public void handle(@CookieValue("JSESSIONID") String cookie) { // <1> @@ -30,7 +30,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/demo") fun handle(@CookieValue("JSESSIONID") cookie: String) { // <1> diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/httpentity.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/httpentity.adoc index 1c9d77d8494e..5711095c0706 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/httpentity.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/httpentity.adoc @@ -11,7 +11,7 @@ container object that exposes request headers and the body. The following exampl ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") public void handle(HttpEntity entity) { @@ -21,7 +21,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") fun handle(entity: HttpEntity) { diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/jackson.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/jackson.adoc index ba07fff65a59..9573aadfaa15 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/jackson.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/jackson.adoc @@ -17,7 +17,7 @@ which allows rendering only a subset of all fields in an `Object`. To use it wit ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RestController public class UserController { @@ -59,7 +59,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RestController class UserController { diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/matrix-variables.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/matrix-variables.adoc index 02d4555997dd..805bbe54753c 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/matrix-variables.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/matrix-variables.adoc @@ -23,7 +23,7 @@ variables are expected. The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // GET /pets/42;q=11;r=22 @@ -37,7 +37,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // GET /pets/42;q=11;r=22 @@ -59,7 +59,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // GET /owners/42;q=11/pets/21;q=22 @@ -75,7 +75,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/owners/{ownerId}/pets/{petId}") fun findPet( @@ -95,7 +95,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // GET /pets/42 @@ -108,7 +108,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // GET /pets/42 @@ -126,7 +126,7 @@ To get all matrix variables, use a `MultiValueMap`, as the following example sho ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // GET /owners/42;q=11;r=12/pets/21;q=22;s=23 @@ -142,7 +142,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // GET /owners/42;q=11;r=12/pets/21;q=22;s=23 diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/modelattrib-method-args.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/modelattrib-method-args.adoc index 45977c0b9e65..24999a0e1726 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/modelattrib-method-args.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/modelattrib-method-args.adoc @@ -3,14 +3,14 @@ [.small]#xref:web/webmvc/mvc-controller/ann-methods/modelattrib-method-args.adoc[See equivalent in the Servlet stack]# -The `@ModelAttribute` method parameter annotation binds request parameters onto a model -object. For example: +The `@ModelAttribute` method parameter annotation binds form data, query parameters, +URI path variables, and request headers onto a model object. For example: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") public String processSubmit(@ModelAttribute Pet pet) { } // <1> @@ -19,7 +19,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") fun processSubmit(@ModelAttribute pet: Pet): String { } // <1> @@ -27,6 +27,10 @@ Kotlin:: <1> Bind to an instance of `Pet`. ====== +Form data and query parameters take precedence over URI variables and headers, which are +included only if they don't override request parameters with the same name. Dashes are +stripped from header names. + The `Pet` instance may be: * Accessed from the model where it could have been added by a @@ -54,7 +58,7 @@ When using constructor binding, you can customize request parameter names throug ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- class Account { @@ -67,7 +71,7 @@ Java:: ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class Account(@BindParam("first-name") val firstName: String) ---- @@ -77,7 +81,11 @@ NOTE: The `@BindParam` may also be placed on the fields that correspond to const parameters. While `@BindParam` is supported out of the box, you can also use a different annotation by setting a `DataBinder.NameResolver` on `DataBinder` -WebFlux, unlike Spring MVC, supports reactive types in the model, e.g. `Mono`. +Constructor binding supports `List`, `Map`, and array arguments either converted from +a single string, for example, comma-separated list, or based on indexed keys such as +`accounts[2].name` or `account[KEY].name`. + +WebFlux, unlike Spring MVC, supports reactive types in the model, for example, `Mono`. You can declare a `@ModelAttribute` argument with or without a reactive type wrapper, and it will be resolved accordingly to the actual value. @@ -89,7 +97,7 @@ in order to handle such errors in the controller method. For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") public String processSubmit(@ModelAttribute("pet") Pet pet, BindingResult result) { <1> @@ -103,7 +111,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") fun processSubmit(@ModelAttribute("pet") pet: Pet, result: BindingResult): String { // <1> @@ -124,7 +132,7 @@ directly through it. For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") public Mono processSubmit(@Valid @ModelAttribute("pet") Mono petMono) { @@ -140,7 +148,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") fun processSubmit(@Valid @ModelAttribute("pet") petMono: Mono): Mono { @@ -164,7 +172,7 @@ xref:web/webmvc/mvc-config/validation.adoc[Spring validation]). For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") public String processSubmit(@Valid @ModelAttribute("pet") Pet pet, BindingResult result) { // <1> @@ -178,7 +186,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") fun processSubmit(@Valid @ModelAttribute("pet") pet: Pet, result: BindingResult): String { // <1> diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/multipart-forms.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/multipart-forms.adoc index 462bf3eccb1f..5616e8110b8f 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/multipart-forms.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/multipart-forms.adoc @@ -13,7 +13,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- class MyForm { @@ -38,7 +38,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MyForm( val name: String, @@ -87,7 +87,7 @@ You can access individual parts with `@RequestPart`, as the following example sh ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") public String handle(@RequestPart("meta-data") Part metadata, // <1> @@ -100,7 +100,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") fun handle(@RequestPart("meta-data") Part metadata, // <1> @@ -122,7 +122,7 @@ you can declare a concrete target `Object`, instead of `Part`, as the following ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") public String handle(@RequestPart("meta-data") MetaData metadata) { // <1> @@ -133,7 +133,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") fun handle(@RequestPart("meta-data") metadata: MetaData): String { // <1> @@ -156,7 +156,7 @@ error related operators: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") public String handle(@Valid @RequestPart("meta-data") Mono metadata) { @@ -166,7 +166,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") fun handle(@Valid @RequestPart("meta-data") metadata: MetaData): String { @@ -188,7 +188,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") public String handle(@RequestBody Mono> parts) { // <1> @@ -199,7 +199,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") fun handle(@RequestBody parts: MultiValueMap): String { // <1> @@ -230,7 +230,7 @@ For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") public void handle(@RequestBody Flux allPartsEvents) { <1> @@ -270,7 +270,7 @@ file upload. Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") fun handle(@RequestBody allPartsEvents: Flux) = { // <1> diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestattrib.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestattrib.adoc index d5359a575b3b..4fc08067bfb3 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestattrib.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestattrib.adoc @@ -11,7 +11,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/") public String handle(@RequestAttribute Client client) { <1> @@ -22,7 +22,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/") fun handle(@RequestAttribute client: Client): String { // <1> diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestbody.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestbody.adoc index b4b78afd28db..90d1ea2c83af 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestbody.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestbody.adoc @@ -11,7 +11,7 @@ The following example uses a `@RequestBody` argument: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") public void handle(@RequestBody Account account) { @@ -21,7 +21,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") fun handle(@RequestBody account: Account) { @@ -37,7 +37,7 @@ and fully non-blocking reading and (client-to-server) streaming. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") public void handle(@RequestBody Mono account) { @@ -47,7 +47,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") fun handle(@RequestBody accounts: Flow) { @@ -70,7 +70,7 @@ related operators: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") public void handle(@Valid @RequestBody Mono account) { @@ -80,7 +80,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") fun handle(@Valid @RequestBody account: Mono) { @@ -96,7 +96,7 @@ that case the request body must not be a `Mono`, and will be resolved first: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") public void handle(@Valid @RequestBody Account account, Errors errors) { @@ -106,7 +106,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") fun handle(@Valid @RequestBody account: Mono) { diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestheader.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestheader.adoc index e186391ef862..64c3fa9a7a9e 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestheader.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestheader.adoc @@ -25,7 +25,7 @@ The following example gets the value of the `Accept-Encoding` and `Keep-Alive` h ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/demo") public void handle( @@ -39,7 +39,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/demo") fun handle( diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestparam.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestparam.adoc index 372c4bfaa6be..0066245d26c6 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestparam.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/requestparam.adoc @@ -10,7 +10,7 @@ controller. The following code snippet shows the usage: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller @RequestMapping("/pets") @@ -32,7 +32,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.ui.set diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/responsebody.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/responsebody.adoc index d6befe47fdc7..d0d34f8d2e23 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/responsebody.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/responsebody.adoc @@ -11,7 +11,7 @@ example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/accounts/{id}") @ResponseBody @@ -22,7 +22,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/accounts/{id}") @ResponseBody diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/responseentity.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/responseentity.adoc index 00de86dad575..21766d338460 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/responseentity.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/responseentity.adoc @@ -9,7 +9,7 @@ ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/something") public ResponseEntity handle() { @@ -21,7 +21,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/something") fun handle(): ResponseEntity { diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/return-types.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/return-types.adoc index 7c06be75bf6c..22fe3570b6c6 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/return-types.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/return-types.adoc @@ -68,6 +68,10 @@ Controllers can then return a `Flux>`; Reactor provides a dedicated oper | `Rendering` | An API for model and view rendering scenarios. +| `FragmentsRendering`, `Flux`, `Collection` +| For rendering one or more fragments each with its own view and model. + See xref:web/webflux-view.adoc#webflux-view-fragments[HTML Fragments] for more details. + | `void` | A method with a `void`, possibly asynchronous (for example, `Mono`), return type (or a `null` return value) is considered to have fully handled the response if it also has a `ServerHttpResponse`, diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/sessionattribute.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/sessionattribute.adoc index 46ebcba9b49b..4469d226435b 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/sessionattribute.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/sessionattribute.adoc @@ -11,7 +11,7 @@ you can use the `@SessionAttribute` annotation on a method parameter, as the fol ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/") public String handle(@SessionAttribute User user) { // <1> @@ -22,7 +22,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/") fun handle(@SessionAttribute user: User): String { // <1> diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/sessionattributes.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/sessionattributes.adoc index d71db97849a7..7bc918c846fd 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/sessionattributes.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-methods/sessionattributes.adoc @@ -15,7 +15,7 @@ Consider the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller @SessionAttributes("pet") <1> @@ -27,7 +27,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller @SessionAttributes("pet") // <1> @@ -47,7 +47,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller @SessionAttributes("pet") // <1> @@ -71,7 +71,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller @SessionAttributes("pet") // <1> diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-modelattrib-methods.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-modelattrib-methods.adoc index fab54b23073a..7d79ea1d468e 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-modelattrib-methods.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-modelattrib-methods.adoc @@ -28,7 +28,7 @@ The following example uses a `@ModelAttribute` method: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ModelAttribute public void populateModel(@RequestParam String number, Model model) { @@ -39,7 +39,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ModelAttribute fun populateModel(@RequestParam number: String, model: Model) { @@ -55,7 +55,7 @@ The following example adds one attribute only: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ModelAttribute public Account addAccount(@RequestParam String number) { @@ -65,7 +65,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ModelAttribute fun addAccount(@RequestParam number: String): Account { @@ -89,7 +89,7 @@ declared without a wrapper, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ModelAttribute public void addAccount(@RequestParam String number) { @@ -105,7 +105,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.ui.set @@ -137,7 +137,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/accounts/{id}") @ModelAttribute("myAccount") @@ -149,7 +149,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/accounts/{id}") @ModelAttribute("myAccount") diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc index 09b30a4a43cf..cbfcad7a7cec 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc @@ -40,7 +40,7 @@ The following example uses type and method level mappings: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RestController @RequestMapping("/persons") @@ -61,7 +61,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RestController @RequestMapping("/persons") @@ -129,7 +129,7 @@ Captured URI variables can be accessed with `@PathVariable`, as the following ex ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/owners/{ownerId}/pets/{petId}") public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) { @@ -139,7 +139,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/owners/{ownerId}/pets/{petId}") fun findPet(@PathVariable ownerId: Long, @PathVariable petId: Long): Pet { @@ -156,7 +156,7 @@ You can declare URI variables at the class and method levels, as the following e ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller @RequestMapping("/owners/{ownerId}") // <1> @@ -173,7 +173,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller @RequestMapping("/owners/{ownerId}") // <1> @@ -213,7 +213,7 @@ extracts the name, version, and file extension: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/{name:[a-z-]+}-{version:\\d\\.\\d\\.\\d}{ext:\\.[a-z]+}") public void handle(@PathVariable String version, @PathVariable String ext) { @@ -223,7 +223,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/{name:[a-z-]+}-{version:\\d\\.\\d\\.\\d}{ext:\\.[a-z]+}") fun handle(@PathVariable version: String, @PathVariable ext: String) { @@ -274,7 +274,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping(path = "/pets", consumes = "application/json") public void addPet(@RequestBody Pet pet) { @@ -284,7 +284,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/pets", consumes = ["application/json"]) fun addPet(@RequestBody pet: Pet) { @@ -315,7 +315,7 @@ content types that a controller method produces, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping(path = "/pets/{petId}", produces = "application/json") @ResponseBody @@ -326,7 +326,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/pets/{petId}", produces = ["application/json"]) @ResponseBody @@ -343,7 +343,7 @@ You can declare a shared `produces` attribute at the class level. Unlike most ot mapping attributes, however, when used at the class level, a method-level `produces` attribute overrides rather than extend the class level declaration. -TIP: `MediaType` provides constants for commonly used media types -- e.g. +TIP: `MediaType` provides constants for commonly used media types -- for example, `APPLICATION_JSON_VALUE`, `APPLICATION_XML_VALUE`. @@ -359,7 +359,7 @@ specific value (`myParam=myValue`). The following examples tests for a parameter ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping(path = "/pets/{petId}", params = "myParam=myValue") // <1> public void findPet(@PathVariable String petId) { @@ -370,7 +370,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/pets/{petId}", params = ["myParam=myValue"]) // <1> fun findPet(@PathVariable petId: String) { @@ -386,7 +386,7 @@ You can also use the same with request header conditions, as the following examp ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping(path = "/pets/{petId}", headers = "myHeader=myValue") // <1> public void findPet(@PathVariable String petId) { @@ -397,7 +397,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/pets/{petId}", headers = ["myHeader=myValue"]) // <1> fun findPet(@PathVariable petId: String) { @@ -469,7 +469,7 @@ under different URLs. The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class MyConfig { @@ -495,7 +495,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class MyConfig { @@ -541,7 +541,7 @@ For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @HttpExchange("/persons") interface PersonService { @@ -569,7 +569,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @HttpExchange("/persons") interface PersonService { @@ -605,4 +605,4 @@ For method parameters and returns values, generally, `@HttpExchange` supports a subset of the method parameters that `@RequestMapping` does. Notably, it excludes any server-side specific parameter types. For details, see the list for xref:integration/rest-clients.adoc#rest-http-interface-method-parameters[@HttpExchange] and -xref:web/webflux/controller/ann-methods/arguments.adoc[@RequestMapping]. +xref:web/webflux/controller/ann-methods/arguments.adoc[@RequestMapping]. \ No newline at end of file diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-validation.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-validation.adoc index 59090a205692..53ff9683c69e 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-validation.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-validation.adoc @@ -65,7 +65,7 @@ methods by controller method parameter type: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HandlerMethodValidationException ex = ... ; @@ -95,7 +95,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // HandlerMethodValidationException val ex diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann.adoc index 93cc097b1577..00241404fca6 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann.adoc @@ -16,11 +16,11 @@ your Java configuration, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan("org.example.web") // <1> - public class WebConfig { + public class WebConfiguration { // ... } @@ -29,11 +29,11 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @ComponentScan("org.example.web") // <1> - class WebConfig { + class WebConfiguration { // ... } diff --git a/framework-docs/modules/ROOT/pages/web/webflux/dispatcher-handler.adoc b/framework-docs/modules/ROOT/pages/web/webflux/dispatcher-handler.adoc index a621320538ae..8a17248f5ee1 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/dispatcher-handler.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/dispatcher-handler.adoc @@ -29,7 +29,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ApplicationContext context = ... HttpHandler handler = WebHttpHandlerBuilder.applicationContext(context).build(); @@ -37,7 +37,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val context: ApplicationContext = ... val handler = WebHttpHandlerBuilder.applicationContext(context).build() @@ -170,7 +170,7 @@ A `HandlerAdapter` may expose its exception handling mechanism as a A `HandlerAdapter` may also choose to implement `DispatchExceptionHandler`. In that case `DispatcherHandler` will apply it to exceptions that arise before a handler is mapped, -e.g. during handler mapping, or earlier, e.g. in a `WebFilter`. +for example, during handler mapping, or earlier, for example, in a `WebFilter`. See also xref:web/webflux/controller/ann-exceptions.adoc[Exceptions] in the "`Annotated Controller`" section or xref:web/webflux/reactive-spring.adoc#webflux-exception-handler[Exceptions] in the WebHandler API section. @@ -184,9 +184,11 @@ xref:web/webflux/reactive-spring.adoc#webflux-exception-handler[Exceptions] in t View resolution enables rendering to a browser with an HTML template and a model without tying you to a specific view technology. In Spring WebFlux, view resolution is supported through a dedicated xref:web/webflux/dispatcher-handler.adoc#webflux-resulthandling[HandlerResultHandler] that uses - `ViewResolver` instances to map a String (representing a logical view name) to a `View` +`ViewResolver` instances to map a String (representing a logical view name) to a `View` instance. The `View` is then used to render the response. +Web applications need to use a xref:web/webflux-view.adoc[View rendering library] to support this use case. + [[webflux-viewresolution-handling]] === Handling @@ -238,6 +240,9 @@ operate in terms of logical view names. A view name such as `redirect:/some/resource` is relative to the current application, while a view name such as `redirect:https://example.com/arbitrary/path` redirects to an absolute URL. +NOTE: xref:web/webmvc/mvc-servlet/viewresolver.adoc#mvc-redirecting-forward-prefix[Unlike the Servlet stack], +Spring WebFlux does not support "FORWARD" dispatches, so `forward:` prefixes are not supported as a result. + [[webflux-multiple-representations]] === Content Negotiation diff --git a/framework-docs/modules/ROOT/pages/web/webflux/new-framework.adoc b/framework-docs/modules/ROOT/pages/web/webflux/new-framework.adoc index b03cefb04bbe..9b7022fb72a2 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/new-framework.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/new-framework.adoc @@ -101,7 +101,7 @@ On that foundation, Spring WebFlux provides a choice of two programming models: from the `spring-web` module. Both Spring MVC and WebFlux controllers support reactive (Reactor and RxJava) return types, and, as a result, it is not easy to tell them apart. One notable difference is that WebFlux also supports reactive `@RequestBody` arguments. -* <>: Lambda-based, lightweight, and functional programming model. You can think of +* xref:web/webflux-functional.adoc[Functional Endpoints]: Lambda-based, lightweight, and functional programming model. You can think of this as a small library or a set of utilities that an application can use to route and handle requests. The big difference with annotated controllers is that the application is in charge of request handling from start to finish versus declaring intent through diff --git a/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc b/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc index 13d527592cc8..6e980e5197e4 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc @@ -88,7 +88,7 @@ The code snippets below show using the `HttpHandler` adapters with each server A ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HttpHandler handler = ... ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(handler); @@ -97,7 +97,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val handler: HttpHandler = ... val adapter = ReactorHttpHandlerAdapter(handler) @@ -110,7 +110,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HttpHandler handler = ... UndertowHttpHandlerAdapter adapter = new UndertowHttpHandlerAdapter(handler); @@ -120,7 +120,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val handler: HttpHandler = ... val adapter = UndertowHttpHandlerAdapter(handler) @@ -134,7 +134,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HttpHandler handler = ... Servlet servlet = new TomcatHttpHandlerAdapter(handler); @@ -151,7 +151,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val handler: HttpHandler = ... val servlet = TomcatHttpHandlerAdapter(handler) @@ -173,7 +173,7 @@ Kotlin:: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HttpHandler handler = ... Servlet servlet = new JettyHttpHandlerAdapter(handler); @@ -192,7 +192,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val handler: HttpHandler = ... val servlet = JettyHttpHandlerAdapter(handler) @@ -305,14 +305,14 @@ Spring ApplicationContext, or that can be registered directly with it: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Mono> getFormData(); ---- Kotlin:: + -[source,Kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,Kotlin,indent=0,subs="verbatim,quotes"] ---- suspend fun getFormData(): MultiValueMap ---- @@ -334,14 +334,14 @@ The `DefaultServerWebExchange` uses the configured `HttpMessageReader` to parse ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Mono> getMultipartData(); ---- Kotlin:: + -[source,Kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,Kotlin,indent=0,subs="verbatim,quotes"] ---- suspend fun getMultipartData(): MultiValueMap ---- @@ -419,6 +419,24 @@ controllers. However, when you use it with Spring Security, we advise relying on See the section on xref:web/webflux-cors.adoc[CORS] and the xref:web/webflux-cors.adoc#webflux-cors-webfilter[CORS `WebFilter`] for more details. +[[filters.url-handler]] +=== URL Handler +[.small]#xref:web/webmvc/filters.adoc#filters.url-handler[See equivalent in the Servlet stack]# + +You may want your controller endpoints to match routes with or without a trailing slash in the URL path. +For example, both "GET /home" and "GET /home/" should be handled by a controller method annotated with `@GetMapping("/home")`. + +Adding trailing slash variants to all mapping declarations is not the best way to handle this use case. +The `UrlHandlerFilter` web filter has been designed for this purpose. It can be configured to: + +* respond with an HTTP redirect status when receiving URLs with trailing slashes, sending browsers to the non-trailing slash URL variant. +* mutate the request to act as if the request was sent without a trailing slash and continue the processing of the request. + +Here is how you can instantiate and configure a `UrlHandlerFilter` for a blog application: + +include-code::./UrlHandlerFilterConfiguration[tag=config,indent=0] + + [[webflux-exception-handler]] == Exceptions @@ -453,7 +471,7 @@ The following table describes the available `WebExceptionHandler` implementation [[webflux-codecs]] == Codecs -[.small]#xref:integration/rest-clients.adoc#rest-message-conversion[See equivalent in the Servlet stack]# +[.small]#xref:web/webmvc/message-converters.adoc#message-converters[See equivalent in the Servlet stack]# The `spring-web` and `spring-core` modules provide support for serializing and deserializing byte content to and from higher level objects through non-blocking I/O with @@ -468,7 +486,7 @@ to encode and decode HTTP message content. * An `Encoder` can be wrapped with `EncoderHttpMessageWriter` to adapt it for use in a web application, while a `Decoder` can be wrapped with `DecoderHttpMessageReader`. * {spring-framework-api}/core/io/buffer/DataBuffer.html[`DataBuffer`] abstracts different -byte buffer representations (e.g. Netty `ByteBuf`, `java.nio.ByteBuffer`, etc.) and is +byte buffer representations (for example, Netty `ByteBuf`, `java.nio.ByteBuffer`, etc.) and is what all codecs work on. See xref:core/databuffer-codec.adoc[Data Buffers and Codecs] in the "Spring Core" section for more on this topic. @@ -493,8 +511,8 @@ The `Jackson2Decoder` works as follows: * Jackson's asynchronous, non-blocking parser is used to aggregate a stream of byte chunks into ``TokenBuffer``'s each representing a JSON object. * Each `TokenBuffer` is passed to Jackson's `ObjectMapper` to create a higher level object. -* When decoding to a single-value publisher (e.g. `Mono`), there is one `TokenBuffer`. -* When decoding to a multi-value publisher (e.g. `Flux`), each `TokenBuffer` is passed to +* When decoding to a single-value publisher (for example, `Mono`), there is one `TokenBuffer`. +* When decoding to a multi-value publisher (for example, `Flux`), each `TokenBuffer` is passed to the `ObjectMapper` as soon as enough bytes are received for a fully formed object. The input content can be a JSON array, or any https://en.wikipedia.org/wiki/JSON_streaming[line-delimited JSON] format such as NDJSON, @@ -502,7 +520,7 @@ JSON Lines, or JSON Text Sequences. The `Jackson2Encoder` works as follows: -* For a single value publisher (e.g. `Mono`), simply serialize it through the +* For a single value publisher (for example, `Mono`), simply serialize it through the `ObjectMapper`. * For a multi-value publisher with `application/json`, by default collect the values with `Flux#collectToList()` and then serialize the resulting collection. @@ -562,6 +580,18 @@ for repeated, map-like access to parts, or otherwise rely on the `SynchronossPartHttpMessageReader` for a one-time access to `Flux`. +[[webflux-codecs-protobuf]] +=== Protocol Buffers + +`ProtobufEncoder` and `ProtobufDecoder` supporting decoding and encoding "application/x-protobuf", "application/octet-stream" +and "application/vnd.google.protobuf" content for `com.google.protobuf.Message` types. They also support stream of values +if content is received/sent with the "delimited" parameter along the content type (like "application/x-protobuf;delimited=true"). +This requires the "com.google.protobuf:protobuf-java" library, version 3.29 and higher. + +The `ProtobufJsonDecoder` and `ProtobufJsonEncoder` variants support reading and writing JSON documents to and from Protobuf messages. +They require the "com.google.protobuf:protobuf-java-util" dependency. Note, the JSON variants do not support reading stream of messages, +see the {spring-framework-api}/http/codec/protobuf/ProtobufJsonDecoder.html[javadoc of `ProtobufJsonDecoder`] for more details. + [[webflux-codecs-limits]] === Limits @@ -664,7 +694,7 @@ The following example shows how to do so for server-side requests: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -679,7 +709,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebFlux @@ -698,7 +728,7 @@ The following example shows how to do so for client-side requests: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- Consumer consumer = configurer -> configurer.defaultCodecs().enableLoggingRequestDetails(true); @@ -710,7 +740,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val consumer: (ClientCodecConfigurer) -> Unit = { configurer -> configurer.defaultCodecs().enableLoggingRequestDetails(true) } @@ -748,7 +778,7 @@ The following example shows how to do so for client-side requests: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- WebClient webClient = WebClient.builder() .codecs(configurer -> { @@ -760,7 +790,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val webClient = WebClient.builder() .codecs({ configurer -> diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-cors.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-cors.adoc index a8e7bb148b64..032311bff3dc 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-cors.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-cors.adoc @@ -1,5 +1,6 @@ [[mvc-cors]] = CORS + [.small]#xref:web/webflux-cors.adoc[See equivalent in the Reactive stack]# Spring MVC lets you handle CORS (Cross-Origin Resource Sharing). This section @@ -88,7 +89,7 @@ class- or method-level `@CrossOrigin` annotations (other handlers can implement The rules for combining global and local configuration are generally additive -- for example, all global and all local origins. For those attributes where only a single value can be -accepted, e.g. `allowCredentials` and `maxAge`, the local overrides the global value. See +accepted, for example, `allowCredentials` and `maxAge`, the local overrides the global value. See {spring-framework-api}/web/cors/CorsConfiguration.html#combine-org.springframework.web.cors.CorsConfiguration-[`CorsConfiguration#combine(CorsConfiguration)`] for more details. @@ -116,7 +117,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RestController @RequestMapping("/account") @@ -137,7 +138,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RestController @RequestMapping("/account") @@ -178,7 +179,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @CrossOrigin(origins = "https://domain2.com", maxAge = 3600) @RestController @@ -199,7 +200,7 @@ public class AccountController { Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @CrossOrigin(origins = ["https://domain2.com"], maxAge = 3600) @RestController @@ -225,7 +226,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @CrossOrigin(maxAge = 3600) @RestController @@ -247,7 +248,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @CrossOrigin(maxAge = 3600) @RestController @@ -308,7 +309,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebMvc @@ -331,7 +332,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebMvc @@ -398,7 +399,7 @@ following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim",role="primary"] +[source,java,indent=0,subs="verbatim"] ---- CorsConfiguration config = new CorsConfiguration(); @@ -418,7 +419,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim",role="secondary"] +[source,kotlin,indent=0,subs="verbatim"] ---- val config = CorsConfiguration() diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-functional.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-functional.adoc index 276adcade006..c56df6c842cb 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-functional.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-functional.adoc @@ -1,6 +1,7 @@ [[webmvc-fn]] = Functional Endpoints -[.small]#<># + +[.small]#xref:web/webflux-functional.adoc[See equivalent in the Reactive stack]# Spring Web MVC includes WebMvc.fn, a lightweight functional programming model in which functions are used to route and handle requests and contracts are designed for immutability. @@ -34,7 +35,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.web.servlet.function.RequestPredicates.*; @@ -71,7 +72,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.web.servlet.function.router @@ -134,14 +135,14 @@ The following example extracts the request body to a `String`: ====== Java:: + -[source,java,role="primary"] +[source,java] ---- String string = request.body(String.class); ---- Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- val string = request.body() ---- @@ -155,14 +156,14 @@ where `Person` objects are decoded from a serialized form, such as JSON or XML: ====== Java:: + -[source,java,role="primary"] +[source,java] ---- List people = request.body(new ParameterizedTypeReference>() {}); ---- Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- val people = request.body() ---- @@ -174,14 +175,14 @@ The following example shows how to access parameters: ====== Java:: + -[source,java,role="primary"] +[source,java] ---- MultiValueMap params = request.params(); ---- Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- val map = request.params() ---- @@ -200,7 +201,7 @@ content: ====== Java:: + -[source,java,role="primary"] +[source,java] ---- Person person = ... ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(person); @@ -208,7 +209,7 @@ ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(person); Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- val person: Person = ... ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(person) @@ -221,7 +222,7 @@ The following example shows how to build a 201 (CREATED) response with a `Locati ====== Java:: + -[source,java,role="primary"] +[source,java] ---- URI location = ... ServerResponse.created(location).build(); @@ -229,7 +230,7 @@ ServerResponse.created(location).build(); Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- val location: URI = ... ServerResponse.created(location).build() @@ -243,7 +244,7 @@ You can also use an asynchronous result as the body, in the form of a `Completab ====== Java:: + -[source,java,role="primary"] +[source,java] ---- Mono person = webClient.get().retrieve().bodyToMono(Person.class); ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(person); @@ -251,7 +252,7 @@ ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(person); Kotlin:: + -[source,kotlin,role="secondary"] +[source,kotlin] ---- val person = webClient.get().retrieve().awaitBody() ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(person) @@ -267,7 +268,7 @@ any other asynchronous type supported by the `ReactiveAdapterRegistry`. For inst ====== Java:: + -[source,java,role="primary"] +[source,java] ---- Mono asyncResponse = webClient.get().retrieve().bodyToMono(Person.class) .map(p -> ServerResponse.ok().header("Name", p.name()).body(p)); @@ -283,7 +284,7 @@ allows you to send Strings, or other objects as JSON. For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public RouterFunction sse() { return route(GET("/sse"), request -> ServerResponse.sse(sseBuilder -> { @@ -309,7 +310,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- fun sse(): RouterFunction = router { GET("/sse") { request -> ServerResponse.sse { sseBuilder -> @@ -346,7 +347,7 @@ We can write a handler function as a lambda, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HandlerFunction helloWorld = request -> ServerResponse.ok().body("Hello World"); @@ -354,7 +355,7 @@ HandlerFunction helloWorld = Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val helloWorld: (ServerRequest) -> ServerResponse = { ServerResponse.ok().body("Hello World") } @@ -373,7 +374,7 @@ For example, the following class exposes a reactive `Person` repository: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.web.reactive.function.server.ServerResponse.ok; @@ -419,7 +420,7 @@ found. If it is not found, we return a 404 Not Found response. Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class PersonHandler(private val repository: PersonRepository) { @@ -463,7 +464,7 @@ xref:web/webmvc/mvc-config/validation.adoc[Validator] implementation for a `Pers ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class PersonHandler { @@ -493,7 +494,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class PersonHandler(private val repository: PersonRepository) { @@ -563,7 +564,7 @@ header: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RouterFunction route = RouterFunctions.route() .GET("/hello-world", accept(MediaType.TEXT_PLAIN), @@ -572,7 +573,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.web.servlet.function.router @@ -624,7 +625,7 @@ The following example shows the composition of four routes: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.web.servlet.function.RequestPredicates.*; @@ -651,7 +652,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.http.MediaType.APPLICATION_JSON import org.springframework.web.servlet.function.router @@ -693,7 +694,7 @@ For instance, the last few lines of the example above can be improved in the fol ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RouterFunction route = route() .path("/person", builder -> builder // <1> @@ -706,7 +707,7 @@ RouterFunction route = route() Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.web.servlet.function.router @@ -730,7 +731,7 @@ We can further improve by using the `nest` method together with `accept`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RouterFunction route = route() .path("/person", b1 -> b1 @@ -743,7 +744,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.web.servlet.function.router @@ -778,10 +779,10 @@ for handling redirects in Single Page Applications. ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- ClassPathResource index = new ClassPathResource("static/index.html"); - List extensions = Arrays.asList("js", "css", "ico", "png", "jpg", "gif"); + List extensions = List.of("js", "css", "ico", "png", "jpg", "gif"); RequestPredicate spaPredicate = path("/api/**").or(path("/error")).or(pathExtension(extensions::contains)).negate(); RouterFunction redirectToIndex = route() .resource(spaPredicate, index) @@ -790,7 +791,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val redirectToIndex = router { val index = ClassPathResource("static/index.html") @@ -811,17 +812,17 @@ It is also possible to route requests that match a given pattern to resources re ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- - Resource location = new FileSystemResource("public-resources/"); + Resource location = new FileUrlResource("public-resources/"); RouterFunction resources = RouterFunctions.resources("/resources/**", location); ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- - val location = FileSystemResource("public-resources/") + val location = FileUrlResource("public-resources/") val resources = router { resources("/resources/**", location) } ---- ====== @@ -853,7 +854,7 @@ The following example shows a WebFlux Java configuration: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableMvc @@ -890,7 +891,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableMvc @@ -941,7 +942,7 @@ For instance, consider the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- RouterFunction route = route() .path("/person", b1 -> b1 @@ -960,7 +961,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.web.servlet.function.router @@ -998,7 +999,7 @@ The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- SecurityManager securityManager = ... @@ -1021,7 +1022,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.web.servlet.function.router diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-test.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-test.adoc index e5633bcea279..8456bdf01538 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-test.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-test.adoc @@ -16,7 +16,7 @@ See xref:testing/testcontext-framework.adoc[TestContext Framework] for more deta * Spring MVC Test: A framework, also known as `MockMvc`, for testing annotated controllers through the `DispatcherServlet` (that is, supporting annotations), complete with the Spring MVC infrastructure but without an HTTP server. -See xref:testing/spring-mvc-test-framework.adoc[Spring MVC Test] for more details. +See xref:testing/mockmvc.adoc[Spring MVC Test] for more details. * Client-side REST: `spring-test` provides a `MockRestServiceServer` that you can use as a mock server for testing client-side code that internally uses the `RestTemplate`. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-view.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-view.adoc index e6af04b7fe26..a72efa0b4f4d 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-view.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-view.adoc @@ -3,10 +3,11 @@ :page-section-summary-toc: 1 [.small]#xref:web/webflux-view.adoc[See equivalent in the Reactive stack]# -The use of view technologies in Spring MVC is pluggable. Whether you decide to use +The rendering of views in Spring MVC is pluggable. Whether you decide to use Thymeleaf, Groovy Markup Templates, JSPs, or other technologies is primarily a matter of a configuration change. This chapter covers view technologies integrated with Spring MVC. -We assume you are already familiar with xref:web/webmvc/mvc-servlet/viewresolver.adoc[View Resolution]. + +For more context on view rendering, please see xref:web/webmvc/mvc-servlet/viewresolver.adoc[View Resolution]. WARNING: The views of a Spring MVC application live within the internal trust boundaries of that application. Views have access to all the beans of your application context. As diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-document.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-document.adoc index 64a49310d125..469e7e5bd471 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-document.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-document.adoc @@ -36,7 +36,7 @@ A simple PDF view for a word list could extend ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class PdfWordList extends AbstractPdfView { @@ -53,7 +53,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class PdfWordList : AbstractPdfView() { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-feeds.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-feeds.adoc index d12bf374fa40..02f1512afbf8 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-feeds.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-feeds.adoc @@ -14,7 +14,7 @@ empty). The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SampleContentAtomView extends AbstractAtomFeedView { @@ -34,7 +34,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class SampleContentAtomView : AbstractAtomFeedView() { @@ -57,7 +57,7 @@ Similar requirements apply for implementing `AbstractRssFeedView`, as the follow ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class SampleContentRssView extends AbstractRssFeedView { @@ -77,7 +77,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class SampleContentRssView : AbstractRssFeedView() { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-fragments.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-fragments.adoc new file mode 100644 index 000000000000..45e4a57adc4e --- /dev/null +++ b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-fragments.adoc @@ -0,0 +1,118 @@ +[[mvc-view-fragments]] += HTML Fragments +:page-section-summary-toc: 1 + +[.small]#xref:web/webflux-view.adoc#webflux-view-fragments[See equivalent in the Reactive stack]# + +https://htmx.org/[HTMX] and https://turbo.hotwired.dev/[Hotwire Turbo] emphasize an +HTML-over-the-wire approach where clients receive server updates in HTML rather than in JSON. +This allows the benefits of an SPA (single page app) without having to write much or even +any JavaScript. For a good overview and to learn more, please visit their respective +websites. + +In Spring MVC, view rendering typically involves specifying one view and one model. +However, in HTML-over-the-wire a common capability is to send multiple HTML fragments that +the browser can use to update different parts of the page. For this, controller methods +can return `Collection`. For example: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @GetMapping + List handle() { + return List.of(new ModelAndView("posts"), new ModelAndView("comments")); + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @GetMapping + fun handle(): List { + return listOf(ModelAndView("posts"), ModelAndView("comments")) + } +---- +====== + +The same can be done also by returning the dedicated type `FragmentsRendering`: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @GetMapping + FragmentsRendering handle() { + return FragmentsRendering.with("posts").fragment("comments").build(); + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @GetMapping + fun handle(): FragmentsRendering { + return FragmentsRendering.with("posts").fragment("comments").build() + } +---- +====== + +Each fragment can have an independent model, and that model inherits attributes from the +shared model for the request. + +HTMX and Hotwire Turbo support streaming updates over SSE (server-sent events). +A controller can use `SseEmitter` to send `ModelAndView` to render a fragment per event: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @GetMapping + SseEmitter handle() { + SseEmitter emitter = new SseEmitter(); + startWorkerThread(() -> { + try { + emitter.send(SseEmitter.event().data(new ModelAndView("posts"))); + emitter.send(SseEmitter.event().data(new ModelAndView("comments"))); + // ... + } + catch (IOException ex) { + // Cancel sending + } + }); + return emitter; + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @GetMapping + fun handle(): SseEmitter { + val emitter = SseEmitter() + startWorkerThread{ + try { + emitter.send(SseEmitter.event().data(ModelAndView("posts"))) + emitter.send(SseEmitter.event().data(ModelAndView("comments"))) + // ... + } + catch (ex: IOException) { + // Cancel sending + } + } + return emitter + } +---- +====== + +The same can also be done by returning `Flux`, or any other type adaptable +to a Reactive Streams `Publisher` through the `ReactiveAdapterRegistry`. \ No newline at end of file diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-freemarker.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-freemarker.adoc index e0e95083b980..bffd2577eb28 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-freemarker.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-freemarker.adoc @@ -19,7 +19,7 @@ The following example shows how to configure FreeMarker as a view technology: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebMvc @@ -36,6 +36,7 @@ Java:: public FreeMarkerConfigurer freeMarkerConfigurer() { FreeMarkerConfigurer configurer = new FreeMarkerConfigurer(); configurer.setTemplateLoaderPath("/WEB-INF/freemarker"); + configurer.setDefaultCharset(StandardCharsets.UTF_8); return configurer; } } @@ -43,7 +44,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebMvc @@ -58,6 +59,7 @@ Kotlin:: @Bean fun freeMarkerConfigurer() = FreeMarkerConfigurer().apply { setTemplateLoaderPath("/WEB-INF/freemarker") + setDefaultCharset(StandardCharsets.UTF_8) } } ---- @@ -86,6 +88,7 @@ properties, as the following example shows: ---- + ---- @@ -374,7 +377,7 @@ codes with suitable keys, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- protected Map referenceData(HttpServletRequest request) throws Exception { Map cityMap = new LinkedHashMap<>(); @@ -390,7 +393,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- protected fun referenceData(request: HttpServletRequest): Map { val cityMap = linkedMapOf( diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-groovymarkup.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-groovymarkup.adoc index 7793e08c1048..5df5c7a93d6e 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-groovymarkup.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-groovymarkup.adoc @@ -19,7 +19,7 @@ The following example shows how to configure the Groovy Markup Template Engine: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebMvc @@ -43,7 +43,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebMvc diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc index 51ad8f57bf73..8d095e720d5b 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc @@ -189,7 +189,7 @@ hobbies. The following example shows the `Preferences` class: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class Preferences { @@ -225,7 +225,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class Preferences( var receiveNewsletter: Boolean, @@ -592,7 +592,7 @@ called `UserValidator`, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class UserValidator implements Validator { @@ -609,7 +609,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class UserValidator : Validator { @@ -801,7 +801,7 @@ The following example shows the corresponding `@Controller` method: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RequestMapping(method = RequestMethod.DELETE) public String deletePet(@PathVariable int ownerId, @PathVariable int petId) { @@ -812,7 +812,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RequestMapping(method = [RequestMethod.DELETE]) fun deletePet(@PathVariable ownerId: Int, @PathVariable petId: Int): String { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-script.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-script.adoc index adc87d900352..ffb27d22bd53 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-script.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-script.adoc @@ -57,7 +57,7 @@ The following example uses Mustache templates and the Nashorn JavaScript engine: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebMvc @@ -82,7 +82,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebMvc @@ -124,7 +124,7 @@ The controller would look no different for the Java and XML configurations, as t ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller public class SampleController { @@ -140,7 +140,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller class SampleController { @@ -192,7 +192,7 @@ The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebMvc @@ -217,7 +217,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration @EnableWebMvc diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-xslt.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-xslt.adoc index 255c899eb6e2..7fe25e7164a6 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-xslt.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-xslt.adoc @@ -26,7 +26,7 @@ The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @EnableWebMvc @ComponentScan @@ -45,7 +45,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @EnableWebMvc @ComponentScan @@ -74,7 +74,7 @@ handler method being defined as follows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller public class XsltController { @@ -100,7 +100,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.ui.set diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/filters.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/filters.adoc index 823171a36968..ba61dc917b85 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/filters.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/filters.adoc @@ -9,7 +9,11 @@ The `spring-web` module provides some useful filters: * xref:web/webmvc/filters.adoc#filters-forwarded-headers[Forwarded Headers] * xref:web/webmvc/filters.adoc#filters-shallow-etag[Shallow ETag] * xref:web/webmvc/filters.adoc#filters-cors[CORS] +* xref:web/webmvc/filters.adoc#filters.url-handler[URL Handler] +Servlet filters can be configured in the `web.xml` configuration file or using Servlet annotations. +If you are using Spring Boot, you can +{spring-boot-docs}/how-to/webserver.html#howto.webserver.add-servlet-filter-listener.spring-bean[declare them as beans and configure them as part of your application]. [[filters-http-put]] @@ -26,7 +30,7 @@ available through the `ServletRequest.getParameter{asterisk}()` family of method -[[forwarded-headers]] +[[filters-forwarded-headers]] == Forwarded Headers [.small]#xref:web/webflux/reactive-spring.adoc#webflux-forwarded-headers[See equivalent in the Reactive stack]# @@ -109,4 +113,22 @@ See the sections on xref:web/webmvc-cors.adoc[CORS] and the xref:web/webmvc-cors +[[filters.url-handler]] +== URL Handler +[.small]#xref:web/webflux/reactive-spring.adoc#filters.url-handler[See equivalent in the Reactive stack]# + +In previous Spring Framework versions, Spring MVC could be configured to ignore trailing slashes in URL paths +when mapping incoming requests on controller methods. This could be done by enabling the `setUseTrailingSlashMatch` +option on the `PathMatchConfigurer`. This means that sending a "GET /home/" request would be handled by a controller +method annotated with `@GetMapping("/home")`. + +This option has been retired, but applications are still expected to handle such requests in a safe way. +The `UrlHandlerFilter` Servlet filter has been designed for this purpose. It can be configured to: + +* respond with an HTTP redirect status when receiving URLs with trailing slashes, sending browsers to the non-trailing slash URL variant. +* wrap the request to act as if the request was sent without a trailing slash and continue the processing of the request. + +Here is how you can instantiate and configure a `UrlHandlerFilter` for a blog application: + +include-code::./UrlHandlerFilterConfiguration[tag=config,indent=0] diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/message-converters.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/message-converters.adoc new file mode 100644 index 000000000000..2b86c30d5309 --- /dev/null +++ b/framework-docs/modules/ROOT/pages/web/webmvc/message-converters.adoc @@ -0,0 +1,83 @@ +[[message-converters]] += HTTP Message Conversion + +[.small]#xref:web/webflux/reactive-spring.adoc#webflux-codecs[See equivalent in the Reactive stack]# + +The `spring-web` module contains the `HttpMessageConverter` interface for reading and writing the body of HTTP requests and responses through `InputStream` and `OutputStream`. +`HttpMessageConverter` instances are used on the client side (for example, in the `RestClient`) and on the server side (for example, in Spring MVC REST controllers). + +Concrete implementations for the main media (MIME) types are provided in the framework and are, by default, registered with the `RestClient` and `RestTemplate` on the client side and with `RequestMappingHandlerAdapter` on the server side (see xref:web/webmvc/mvc-config/message-converters.adoc[Configuring Message Converters]). + +Several implementations of `HttpMessageConverter` are described below. +Refer to the {spring-framework-api}/http/converter/HttpMessageConverter.html[`HttpMessageConverter` Javadoc] for the complete list. +For all converters, a default media type is used, but you can override it by setting the `supportedMediaTypes` property. + +[[rest-message-converters-tbl]] +.HttpMessageConverter Implementations +[cols="1,3"] +|=== +| MessageConverter | Description + +| `StringHttpMessageConverter` +| An `HttpMessageConverter` implementation that can read and write `String` instances from the HTTP request and response. +By default, this converter supports all text media types(`text/{asterisk}`) and writes with a `Content-Type` of `text/plain`. + +| `FormHttpMessageConverter` +| An `HttpMessageConverter` implementation that can read and write form data from the HTTP request and response. +By default, this converter reads and writes the `application/x-www-form-urlencoded` media type. +Form data is read from and written into a `MultiValueMap`. +The converter can also write (but not read) multipart data read from a `MultiValueMap`. +By default, `multipart/form-data` is supported. +Additional multipart subtypes can be supported for writing form data. +Consult the javadoc for `FormHttpMessageConverter` for further details. + +| `ByteArrayHttpMessageConverter` +| An `HttpMessageConverter` implementation that can read and write byte arrays from the HTTP request and response. +By default, this converter supports all media types (`{asterisk}/{asterisk}`) and writes with a `Content-Type` of `application/octet-stream`. +You can override this by setting the `supportedMediaTypes` property and overriding `getContentType(byte[])`. + +| `MarshallingHttpMessageConverter` +| An `HttpMessageConverter` implementation that can read and write XML by using Spring's `Marshaller` and `Unmarshaller` abstractions from the `org.springframework.oxm` package. +This converter requires a `Marshaller` and `Unmarshaller` before it can be used. +You can inject these through constructor or bean properties. +By default, this converter supports `text/xml` and `application/xml`. + +| `MappingJackson2HttpMessageConverter` +| An `HttpMessageConverter` implementation that can read and write JSON by using Jackson's `ObjectMapper`. +You can customize JSON mapping as needed through the use of Jackson's provided annotations. +When you need further control (for cases where custom JSON serializers/deserializers need to be provided for specific types), you can inject a custom `ObjectMapper` through the `ObjectMapper` property. +By default, this converter supports `application/json`. This requires the `com.fasterxml.jackson.core:jackson-databind` dependency. + +| `MappingJackson2XmlHttpMessageConverter` +| An `HttpMessageConverter` implementation that can read and write XML by using {jackson-github-org}/jackson-dataformat-xml[Jackson XML] extension's `XmlMapper`. +You can customize XML mapping as needed through the use of JAXB or Jackson's provided annotations. +When you need further control (for cases where custom XML serializers/deserializers need to be provided for specific types), you can inject a custom `XmlMapper` through the `ObjectMapper` property. +By default, this converter supports `application/xml`. This requires the `com.fasterxml.jackson.dataformat:jackson-dataformat-xml` dependency. + +| `MappingJackson2CborHttpMessageConverter` +| `com.fasterxml.jackson.dataformat:jackson-dataformat-cbor` + +| `SourceHttpMessageConverter` +| An `HttpMessageConverter` implementation that can read and write `javax.xml.transform.Source` from the HTTP request and response. +Only `DOMSource`, `SAXSource`, and `StreamSource` are supported. +By default, this converter supports `text/xml` and `application/xml`. + +| `GsonHttpMessageConverter` +| An `HttpMessageConverter` implementation that can read and write JSON by using "Google Gson". +This requires the `com.google.code.gson:gson` dependency. + +| `JsonbHttpMessageConverter` +| An `HttpMessageConverter` implementation that can read and write JSON by using the Jakarta Json Bind API. +This requires the `jakarta.json.bind:jakarta.json.bind-api` dependency and an implementation available. + +| `ProtobufHttpMessageConverter` +| An `HttpMessageConverter` implementation that can read and write Protobuf messages in binary format with the `"application/x-protobuf"` +content type. This requires the `com.google.protobuf:protobuf-java` dependency. + +| `ProtobufJsonFormatHttpMessageConverter` +| An `HttpMessageConverter` implementation that can read and write JSON documents to and from Protobuf messages. +This requires the `com.google.protobuf:protobuf-java-util` dependency. + +|=== + + diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-async.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-async.adoc index d8b00ce3f7eb..9e248d5f24ce 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-async.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-async.adoc @@ -25,7 +25,7 @@ return value with `DeferredResult`, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/quotes") @ResponseBody @@ -41,7 +41,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/quotes") @ResponseBody @@ -71,7 +71,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping public Callable processUpload(final MultipartFile file) { @@ -81,7 +81,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping fun processUpload(file: MultipartFile) = Callable { @@ -227,7 +227,7 @@ response, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/events") public ResponseBodyEmitter handle() { @@ -248,7 +248,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/events") fun handle() = ResponseBodyEmitter().apply { @@ -289,7 +289,7 @@ stream from a controller, return `SseEmitter`, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping(path="/events", produces=MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter handle() { @@ -310,7 +310,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) fun handle() = SseEmitter().apply { @@ -348,7 +348,7 @@ return value type to do so, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/download") public StreamingResponseBody handle() { @@ -363,7 +363,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/download") fun handle() = StreamingResponseBody { @@ -445,6 +445,13 @@ directly. For example: } ---- +The following `ThreadLocalAccessor` implementations are provided out of the box: + +* `LocaleContextThreadLocalAccessor` -- propagates `LocaleContext` via `LocaleContextHolder` +* `RequestAttributesThreadLocalAccessor` -- propagates `RequestAttributes` via `RequestContextHolder` + +The above are not registered automatically. You need to register them via `ContextRegistry.getInstance()` on startup. + For more details, see the {micrometer-context-propagation-docs}/[documentation] of the Micrometer Context Propagation library. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-rest-exceptions.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-rest-exceptions.adoc index 89d62147be1a..03c2b0b5f681 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-rest-exceptions.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-rest-exceptions.adoc @@ -44,6 +44,10 @@ has an `@ExceptionHandler` method that handles any `ErrorResponse` exception, wh includes all built-in web exceptions. You can add more exception handling methods, and use a protected method to map any exception to a `ProblemDetail`. +You can register `ErrorResponse` interceptors through the +xref:web/webmvc/mvc-config.adoc[MVC Config] with a `WebMvcConfigurer`. Use that to intercept +any RFC 9457 response and take some action. + [[mvc-ann-rest-exceptions-non-standard]] @@ -60,7 +64,7 @@ this `Map`. You can also extend `ProblemDetail` to add dedicated non-standard properties. The copy constructor in `ProblemDetail` allows a subclass to make it easy to be created -from an existing `ProblemDetail`. This could be done centrally, e.g. from an +from an existing `ProblemDetail`. This could be done centrally, for example, from an `@ControllerAdvice` such as `ResponseEntityExceptionHandler` that re-creates the `ProblemDetail` of an exception into a subclass with the additional non-standard fields. @@ -183,7 +187,7 @@ Message codes and arguments for each error are also resolved via `MessageSource` |=== NOTE: Unlike other exceptions, the message arguments for -`MethodArgumentValidException` and `HandlerMethodValidationException` are baed on a list of +`MethodArgumentValidException` and `HandlerMethodValidationException` are based on a list of `MessageSourceResolvable` errors that can also be customized through a xref:core/beans/context-introduction.adoc#context-functionality-messagesource[MessageSource] resource bundle. See diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-caching.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-caching.adoc index 2ae6c92f6c96..5e4a6a782888 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-caching.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-caching.adoc @@ -36,7 +36,7 @@ use case-oriented approach that focuses on the common scenarios: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // Cache for an hour - "Cache-Control: max-age=3600" CacheControl ccCacheOneHour = CacheControl.maxAge(1, TimeUnit.HOURS); @@ -52,7 +52,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Cache for an hour - "Cache-Control: max-age=3600" val ccCacheOneHour = CacheControl.maxAge(1, TimeUnit.HOURS) @@ -91,7 +91,7 @@ settings to a `ResponseEntity`, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/book/{id}") public ResponseEntity showBook(@PathVariable Long id) { @@ -109,7 +109,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/book/{id}") fun showBook(@PathVariable id: Long): ResponseEntity { @@ -139,7 +139,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RequestMapping public String myHandleMethod(WebRequest request, Model model) { @@ -160,7 +160,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RequestMapping fun myHandleMethod(request: WebRequest, model: Model): String? { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/advanced-java.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/advanced-java.adoc index dbcdcaccb015..b4f501e7331c 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/advanced-java.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/advanced-java.adoc @@ -12,30 +12,7 @@ For advanced mode, you can remove `@EnableWebMvc` and extend directly from `DelegatingWebMvcConfiguration` instead of implementing `WebMvcConfigurer`, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - public class WebConfig extends DelegatingWebMvcConfiguration { - - // ... - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - class WebConfig : DelegatingWebMvcConfiguration() { - - // ... - } ----- -====== +include-code::./WebConfiguration[tag=snippet,indent=0] You can keep existing methods in `WebConfig`, but you can now also override bean declarations from the base class, and you can still have any number of other `WebMvcConfigurer` implementations on diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/advanced-xml.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/advanced-xml.adoc index bb7203372647..cc5a1130bc9f 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/advanced-xml.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/advanced-xml.adoc @@ -5,39 +5,7 @@ The MVC namespace does not have an advanced mode. If you need to customize a pro a bean that you cannot change otherwise, you can use the `BeanPostProcessor` lifecycle hook of the Spring `ApplicationContext`, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Component - public class MyPostProcessor implements BeanPostProcessor { - - public Object postProcessBeforeInitialization(Object bean, String name) throws BeansException { - // ... - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Component - class MyPostProcessor : BeanPostProcessor { - - override fun postProcessBeforeInitialization(bean: Any, name: String): Any { - // ... - } - } ----- -====== - +include-code::./MyPostProcessor[tag=snippet,indent=0] Note that you need to declare `MyPostProcessor` as a bean, either explicitly in XML or by letting it be detected through a `` declaration. - - - - diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/content-negotiation.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/content-negotiation.adoc index c209826dcbf8..3850a9931ba1 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/content-negotiation.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/content-negotiation.adoc @@ -13,59 +13,9 @@ strategy over path extensions. See xref:web/webmvc/mvc-controller/ann-requestmapping.adoc#mvc-ann-requestmapping-suffix-pattern-match[Suffix Match] and xref:web/webmvc/mvc-controller/ann-requestmapping.adoc#mvc-ann-requestmapping-rfd[Suffix Match and RFD] for more details. -In Java configuration, you can customize requested content type resolution, as the -following example shows: +You can customize requested content type resolution, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - @Override - public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { - configurer.mediaType("json", MediaType.APPLICATION_JSON); - configurer.mediaType("xml", MediaType.APPLICATION_XML); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - override fun configureContentNegotiation(configurer: ContentNegotiationConfigurer) { - configurer.mediaType("json", MediaType.APPLICATION_JSON) - configurer.mediaType("xml", MediaType.APPLICATION_XML) - } - } ----- -====== - - -The following example shows how to achieve the same configuration in XML: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - json=application/json - xml=application/xml - - - ----- +include-code::./WebConfiguration[tag=snippet,indent=0] diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/conversion.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/conversion.adoc index bce9b2e137ce..54a92fa56bb5 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/conversion.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/conversion.adoc @@ -4,121 +4,19 @@ [.small]#xref:web/webflux/config.adoc#webflux-config-conversion[See equivalent in the Reactive stack]# By default, formatters for various number and date types are installed, along with support -for customization via `@NumberFormat` and `@DateTimeFormat` on fields and parameters. +for customization via `@NumberFormat`, `@DurationFormat`, and `@DateTimeFormat` on fields +and parameters. -To register custom formatters and converters in Java config, use the following: +To register custom formatters and converters, use the following: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - @Override - public void addFormatters(FormatterRegistry registry) { - // ... - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - override fun addFormatters(registry: FormatterRegistry) { - // ... - } - } ----- -====== - -To do the same in XML config, use the following: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - - - - - - - - - - - - - - - - - ----- +include-code::./WebConfiguration[tag=snippet,indent=0] By default Spring MVC considers the request Locale when parsing and formatting date values. This works for forms where dates are represented as Strings with "input" form fields. For "date" and "time" form fields, however, browsers use a fixed format defined in the HTML spec. For such cases date and time formatting can be customized as follows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - @Override - public void addFormatters(FormatterRegistry registry) { - DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar(); - registrar.setUseIsoFormat(true); - registrar.registerFormatters(registry); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - override fun addFormatters(registry: FormatterRegistry) { - val registrar = DateTimeFormatterRegistrar() - registrar.setUseIsoFormat(true) - registrar.registerFormatters(registry) - } - } ----- -====== +include-code::./DateTimeWebConfiguration[tag=snippet,indent=0] NOTE: See xref:core/validation/format.adoc#format-FormatterRegistrar-SPI[the `FormatterRegistrar` SPI] and the `FormattingConversionServiceFactoryBean` for more information on when to use diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/customize.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/customize.adoc index dd7cf7e635e1..a42ea1388a44 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/customize.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/customize.adoc @@ -6,33 +6,7 @@ In Java configuration, you can implement the `WebMvcConfigurer` interface, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - // Implement configuration methods... - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - // Implement configuration methods... - } ----- -====== - +include-code::./WebConfiguration[tag=snippet,indent=0] In XML, you can check attributes and sub-elements of ``. You can view the https://schema.spring.io/mvc/spring-mvc.xsd[Spring MVC XML schema] or use diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/default-servlet-handler.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/default-servlet-handler.adoc index 8251cd53528b..e983842eaf64 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/default-servlet-handler.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/default-servlet-handler.adoc @@ -15,93 +15,15 @@ lower than that of the `DefaultServletHttpRequestHandler`, which is `Integer.MAX The following example shows how to enable the feature by using the default setup: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - @Override - public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) { - configurer.enable(); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - override fun configureDefaultServletHandling(configurer: DefaultServletHandlerConfigurer) { - configurer.enable() - } - } ----- -====== - -The following example shows how to achieve the same configuration in XML: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - ----- +include-code::./WebConfiguration[tag=snippet,indent=0] The caveat to overriding the `/` Servlet mapping is that the `RequestDispatcher` for the default Servlet must be retrieved by name rather than by path. The `DefaultServletHttpRequestHandler` tries to auto-detect the default Servlet for the container at startup time, using a list of known names for most of the major Servlet -containers (including Tomcat, Jetty, GlassFish, JBoss, Resin, WebLogic, and WebSphere). +containers (including Tomcat, Jetty, GlassFish, JBoss, WebLogic, and WebSphere). If the default Servlet has been custom-configured with a different name, or if a different Servlet container is being used where the default Servlet name is unknown, then you must explicitly provide the default Servlet's name, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - @Override - public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) { - configurer.enable("myCustomDefaultServlet"); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - override fun configureDefaultServletHandling(configurer: DefaultServletHandlerConfigurer) { - configurer.enable("myCustomDefaultServlet") - } - } ----- -====== - - -The following example shows how to achieve the same configuration in XML: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - ----- - - - +include-code::./CustomDefaultServletConfiguration[tag=snippet,indent=0] diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/enable.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/enable.adoc index bec619f91f3d..b5beda90a007 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/enable.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/enable.adoc @@ -3,50 +3,11 @@ [.small]#xref:web/webflux/config.adoc#webflux-config-enable[See equivalent in the Reactive stack]# -In Java configuration, you can use the `@EnableWebMvc` annotation to enable MVC -configuration, as the following example shows: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig { - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig ----- -====== - -In XML configuration, you can use the `` element to enable MVC -configuration, as the following example shows: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - ----- +You can use the `@EnableWebMvc` annotation to enable MVC configuration with programmatic configuration, or `` with XML configuration, as the following example shows: + +include-code::./WebConfiguration[tag=snippet,indent=0] + +NOTE: When using Spring Boot, you may want to use `@Configuration` classes of type `WebMvcConfigurer` but without `@EnableWebMvc` to keep Spring Boot MVC customizations. See more details in xref:web/webmvc/mvc-config/customize.adoc[the MVC Config API section] and in {spring-boot-docs-ref}/web/servlet.html#web.servlet.spring-mvc.auto-configuration[the dedicated Spring Boot documentation]. The preceding example registers a number of Spring MVC xref:web/webmvc/mvc-servlet/special-bean-types.adoc[infrastructure beans] and adapts to dependencies diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/interceptors.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/interceptors.adoc index aa9f1f8a4277..d5354b4b297d 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/interceptors.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/interceptors.adoc @@ -1,56 +1,9 @@ [[mvc-config-interceptors]] = Interceptors -In Java configuration, you can register interceptors to apply to incoming requests, as -the following example shows: +You can register interceptors to apply to incoming requests, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - @Override - public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(new LocaleChangeInterceptor()); - registry.addInterceptor(new ThemeChangeInterceptor()).addPathPatterns("/**").excludePathPatterns("/admin/**"); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - override fun addInterceptors(registry: InterceptorRegistry) { - registry.addInterceptor(LocaleChangeInterceptor()) - registry.addInterceptor(ThemeChangeInterceptor()).addPathPatterns("/**").excludePathPatterns("/admin/**") - } - } ----- -====== - -The following example shows how to achieve the same configuration in XML: - -[source,xml,indent=0,subs="verbatim"] ----- - - - - - - - - ----- +include-code::./WebConfiguration[tag=snippet,indent=0] WARNING: Interceptors are not ideally suited as a security layer due to the potential for a mismatch with annotated controller path matching. Generally, we recommend using Spring diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/message-converters.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/message-converters.adoc index b1a0064bef5c..a1e2d63303f2 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/message-converters.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/message-converters.adoc @@ -11,52 +11,14 @@ You can also customize the list of configured message converters at the end by o TIP: In a Spring Boot application, the `WebMvcAutoConfiguration` adds any `HttpMessageConverter` beans it detects, in addition to default converters. Hence, in a -Boot application, prefer to use the {spring-boot-docs}/web.html#web.servlet.spring-mvc.message-converters[HttpMessageConverters] +Boot application, prefer to use the {spring-boot-docs-ref}/web/servlet.html#web.servlet.spring-mvc.message-converters[HttpMessageConverters] mechanism. Or alternatively, use `extendMessageConverters` to modify message converters at the end. -The following example adds XML and Jackson JSON converters with a customized -`ObjectMapper` instead of the default ones: +The following example adds XML and Jackson JSON converters with a customized `ObjectMapper` +instead of the default ones: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfiguration implements WebMvcConfigurer { - - @Override - public void configureMessageConverters(List> converters) { - Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder() - .indentOutput(true) - .dateFormat(new SimpleDateFormat("yyyy-MM-dd")) - .modulesToInstall(new ParameterNamesModule()); - converters.add(new MappingJackson2HttpMessageConverter(builder.build())); - converters.add(new MappingJackson2XmlHttpMessageConverter(builder.createXmlMapper(true).build())); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfiguration : WebMvcConfigurer { - - override fun configureMessageConverters(converters: MutableList>) { - val builder = Jackson2ObjectMapperBuilder() - .indentOutput(true) - .dateFormat(SimpleDateFormat("yyyy-MM-dd")) - .modulesToInstall(ParameterNamesModule()) - converters.add(MappingJackson2HttpMessageConverter(builder.build())) - converters.add(MappingJackson2XmlHttpMessageConverter(builder.createXmlMapper(true).build())) ----- -====== +include-code::./WebConfiguration[tag=snippet,indent=0] In the preceding example, {spring-framework-api}/http/converter/json/Jackson2ObjectMapperBuilder.html[`Jackson2ObjectMapperBuilder`] @@ -75,7 +37,7 @@ It also automatically registers the following well-known modules if they are det * {jackson-github-org}/jackson-datatype-jsr310[jackson-datatype-jsr310]: Support for Java 8 Date and Time API types. * {jackson-github-org}/jackson-datatype-jdk8[jackson-datatype-jdk8]: Support for other Java 8 types, such as `Optional`. -* {jackson-github-org}/jackson-module-kotlin[`jackson-module-kotlin`]: Support for Kotlin classes and data classes. +* {jackson-github-org}/jackson-module-kotlin[jackson-module-kotlin]: Support for Kotlin classes and data classes. NOTE: Enabling indentation with Jackson XML support requires https://search.maven.org/#search%7Cgav%7C1%7Cg%3A%22org.codehaus.woodstox%22%20AND%20a%3A%22woodstox-core-asl%22[`woodstox-core-asl`] @@ -85,29 +47,3 @@ Other interesting Jackson modules are available: * https://github.com/zalando/jackson-datatype-money[jackson-datatype-money]: Support for `javax.money` types (unofficial module). * {jackson-github-org}/jackson-datatype-hibernate[jackson-datatype-hibernate]: Support for Hibernate-specific types and properties (including lazy-loading aspects). - -The following example shows how to achieve the same configuration in XML: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - - - - - - ----- - - - diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/path-matching.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/path-matching.adoc index 989ad29c0c5a..a0f33fee3ec1 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/path-matching.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/path-matching.adoc @@ -7,61 +7,6 @@ You can customize options related to path matching and treatment of the URL. For details on the individual options, see the {spring-framework-api}/web/servlet/config/annotation/PathMatchConfigurer.html[`PathMatchConfigurer`] javadoc. -The following example shows how to customize path matching in Java configuration: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - @Override - public void configurePathMatch(PathMatchConfigurer configurer) { - configurer.addPathPrefix("/api", HandlerTypePredicate.forAnnotation(RestController.class)); - } - - private PathPatternParser patternParser() { - // ... - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - override fun configurePathMatch(configurer: PathMatchConfigurer) { - configurer.addPathPrefix("/api", HandlerTypePredicate.forAnnotation(RestController::class.java)) - } - - fun patternParser(): PathPatternParser { - //... - } - } ----- -====== - -The following example shows how to customize path matching in XML configuration: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - ----- - - +The following example shows how to customize path matching: +include-code::./WebConfiguration[tag=snippet,indent=0] diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/static-resources.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/static-resources.adoc index e56686152863..362adf674df0 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/static-resources.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/static-resources.adoc @@ -13,52 +13,9 @@ expiration to ensure maximum use of the browser cache and a reduction in HTTP re made by the browser. The `Last-Modified` information is deduced from `Resource#lastModified` so that HTTP conditional requests are supported with `"Last-Modified"` headers. -The following listing shows how to do so with Java configuration: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - registry.addResourceHandler("/resources/**") - .addResourceLocations("/public", "classpath:/static/") - .setCacheControl(CacheControl.maxAge(Duration.ofDays(365))); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - override fun addResourceHandlers(registry: ResourceHandlerRegistry) { - registry.addResourceHandler("/resources/**") - .addResourceLocations("/public", "classpath:/static/") - .setCacheControl(CacheControl.maxAge(Duration.ofDays(365))) - } - } ----- -====== - -The following example shows how to achieve the same configuration in XML: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - ----- +The following listing shows how to do so: + +include-code::./WebConfiguration[tag=snippet,indent=0] See also xref:web/webmvc/mvc-caching.adoc#mvc-caching-static-resources[HTTP caching support for static resources]. @@ -73,60 +30,9 @@ computed from the content, a fixed application version, or other. A `ContentVersionStrategy` (MD5 hash) is a good choice -- with some notable exceptions, such as JavaScript resources used with a module loader. -The following example shows how to use `VersionResourceResolver` in Java configuration: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - registry.addResourceHandler("/resources/**") - .addResourceLocations("/public/") - .resourceChain(true) - .addResolver(new VersionResourceResolver().addContentVersionStrategy("/**")); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - override fun addResourceHandlers(registry: ResourceHandlerRegistry) { - registry.addResourceHandler("/resources/**") - .addResourceLocations("/public/") - .resourceChain(true) - .addResolver(VersionResourceResolver().addContentVersionStrategy("/**")) - } - } ----- -====== - -The following example shows how to achieve the same configuration in XML: - -[source,xml,indent=0,subs="verbatim"] ----- - - - - - - - - - ----- +The following example shows how to use `VersionResourceResolver`: + +include-code::./VersionedConfiguration[tag=snippet,indent=0] You can then use `ResourceUrlProvider` to rewrite URLs and apply the full chain of resolvers and transformers -- for example, to insert versions. The MVC configuration provides a `ResourceUrlProvider` @@ -152,7 +58,7 @@ include the version of the jar and can also match against incoming URLs without -- for example, from `/webjars/jquery/jquery.min.js` to `/webjars/jquery/1.2.0/jquery.min.js`. TIP: The Java configuration based on `ResourceHandlerRegistry` provides further options -for fine-grained control, e.g. last-modified behavior and optimized resource resolution. +for fine-grained control, for example, last-modified behavior and optimized resource resolution. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/validation.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/validation.adoc index b307b8fc8c8c..b867977160fd 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/validation.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/validation.adoc @@ -8,93 +8,15 @@ on the classpath (for example, Hibernate Validator), the `LocalValidatorFactoryB registered as a global xref:core/validation/validator.adoc[Validator] for use with `@Valid` and `@Validated` on controller method arguments. -In Java configuration, you can customize the global `Validator` instance, as the +You can customize the global `Validator` instance, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - @Override - public Validator getValidator() { - // ... - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - override fun getValidator(): Validator { - // ... - } - } ----- -====== - -The following example shows how to achieve the same configuration in XML: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - ----- +include-code::./WebConfiguration[tag=snippet,indent=0] Note that you can also register `Validator` implementations locally, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Controller - public class MyController { - - @InitBinder - protected void initBinder(WebDataBinder binder) { - binder.addValidators(new FooValidator()); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Controller - class MyController { - - @InitBinder - protected fun initBinder(binder: WebDataBinder) { - binder.addValidators(FooValidator()) - } - } ----- -====== +include-code::./MyController[tag=snippet,indent=0] TIP: If you need to have a `LocalValidatorFactoryBean` injected somewhere, create a bean and mark it with `@Primary` in order to avoid conflict with the one declared in the MVC configuration. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/view-controller.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/view-controller.adoc index 91811b7f415c..47d803b10c80 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/view-controller.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/view-controller.adoc @@ -5,47 +5,9 @@ This is a shortcut for defining a `ParameterizableViewController` that immediate forwards to a view when invoked. You can use it in static cases when there is no Java controller logic to run before the view generates the response. -The following example of Java configuration forwards a request for `/` to a view called `home`: +The following example forwards a request for `/` to a view called `home`: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - @Override - public void addViewControllers(ViewControllerRegistry registry) { - registry.addViewController("/").setViewName("home"); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - override fun addViewControllers(registry: ViewControllerRegistry) { - registry.addViewController("/").setViewName("home") - } - } ----- -====== - -The following example achieves the same thing as the preceding example, but with XML, by -using the `` element: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - ----- +include-code::./WebConfiguration[tag=snippet,indent=0] If an `@RequestMapping` method is mapped to a URL for any HTTP method then a view controller cannot be used to handle the same URL. This is because a match by URL to an diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/view-resolvers.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/view-resolvers.adoc index cea23436efd8..5a0de6171d3f 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/view-resolvers.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/view-resolvers.adoc @@ -5,127 +5,12 @@ The MVC configuration simplifies the registration of view resolvers. -The following Java configuration example configures content negotiation view -resolution by using JSP and Jackson as a default `View` for JSON rendering: +The following example configures content negotiation view resolution by using JSP and Jackson as a +default `View` for JSON rendering: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - @Override - public void configureViewResolvers(ViewResolverRegistry registry) { - registry.enableContentNegotiation(new MappingJackson2JsonView()); - registry.jsp(); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - override fun configureViewResolvers(registry: ViewResolverRegistry) { - registry.enableContentNegotiation(MappingJackson2JsonView()) - registry.jsp() - } - } ----- -====== - - -The following example shows how to achieve the same configuration in XML: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - ----- +include-code::./WebConfiguration[tag=snippet,indent=0] Note, however, that FreeMarker, Groovy Markup, and script templates also require -configuration of the underlying view technology. - -The MVC namespace provides dedicated elements. The following example works with FreeMarker: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - - - - - ----- - -In Java configuration, you can add the respective `Configurer` bean, -as the following example shows: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @EnableWebMvc - public class WebConfig implements WebMvcConfigurer { - - @Override - public void configureViewResolvers(ViewResolverRegistry registry) { - registry.enableContentNegotiation(new MappingJackson2JsonView()); - registry.freeMarker().cache(false); - } - - @Bean - public FreeMarkerConfigurer freeMarkerConfigurer() { - FreeMarkerConfigurer configurer = new FreeMarkerConfigurer(); - configurer.setTemplateLoaderPath("/freemarker"); - return configurer; - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @EnableWebMvc - class WebConfig : WebMvcConfigurer { - - override fun configureViewResolvers(registry: ViewResolverRegistry) { - registry.enableContentNegotiation(MappingJackson2JsonView()) - registry.freeMarker().cache(false) - } - - @Bean - fun freeMarkerConfigurer() = FreeMarkerConfigurer().apply { - setTemplateLoaderPath("/freemarker") - } - } ----- -====== - - +configuration of the underlying view technology. The following example works with FreeMarker: +include-code::./FreeMarkerConfiguration[tag=snippet,indent=0] diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller.adoc index e893eaa75832..d759e804cfb4 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller.adoc @@ -13,7 +13,7 @@ The following example shows a controller defined by annotations: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller public class HelloController { @@ -28,7 +28,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.ui.set diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-advice.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-advice.adoc index 90f206a7c44d..403db0bbf2a8 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-advice.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-advice.adoc @@ -27,7 +27,7 @@ and handlers that they apply to. For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // Target all Controllers annotated with @RestController @ControllerAdvice(annotations = RestController.class) @@ -44,7 +44,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // Target all Controllers annotated with @RestController @ControllerAdvice(annotations = [RestController::class]) diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-exceptionhandler.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-exceptionhandler.adoc index f3fe7f2be8d6..e13037ded80d 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-exceptionhandler.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-exceptionhandler.adoc @@ -6,43 +6,15 @@ `@Controller` and xref:web/webmvc/mvc-controller/ann-advice.adoc[@ControllerAdvice] classes can have `@ExceptionHandler` methods to handle exceptions from controller methods, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Controller - public class SimpleController { - - // ... - - @ExceptionHandler - public ResponseEntity handle(IOException ex) { - // ... - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Controller - class SimpleController { - - // ... - - @ExceptionHandler - fun handle(ex: IOException): ResponseEntity { - // ... - } - } ----- -====== - -The exception may match against a top-level exception being propagated (e.g. a direct -`IOException` being thrown) or against a nested cause within a wrapper exception (e.g. + +include-code::./SimpleController[indent=0] + + +[[mvc-ann-exceptionhandler-exc]] +== Exception Mapping + +The exception may match against a top-level exception being propagated (for example, a direct +`IOException` being thrown) or against a nested cause within a wrapper exception (for example, an `IOException` wrapped inside an `IllegalStateException`). As of 5.3, this can match at arbitrary cause levels, whereas previously only an immediate cause was considered. @@ -54,54 +26,13 @@ is used to sort exceptions based on their depth from the thrown exception type. Alternatively, the annotation declaration may narrow the exception types to match, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @ExceptionHandler({FileSystemException.class, RemoteException.class}) - public ResponseEntity handle(IOException ex) { - // ... - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @ExceptionHandler(FileSystemException::class, RemoteException::class) - fun handle(ex: IOException): ResponseEntity { - // ... - } ----- -====== +include-code::./ExceptionController[tag=narrow,indent=0] You can even use a list of specific exception types with a very generic argument signature, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @ExceptionHandler({FileSystemException.class, RemoteException.class}) - public ResponseEntity handle(Exception ex) { - // ... - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @ExceptionHandler(FileSystemException::class, RemoteException::class) - fun handle(ex: Exception): ResponseEntity { - // ... - } ----- -====== +include-code::./ExceptionController[tag=general,indent=0] + [NOTE] ==== @@ -143,6 +74,25 @@ Support for `@ExceptionHandler` methods in Spring MVC is built on the `Dispatche level, xref:web/webmvc/mvc-servlet/exceptionhandlers.adoc[HandlerExceptionResolver] mechanism. + +[[mvc-ann-exceptionhandler-media]] +== Media Type Mapping +[.small]#xref:web/webflux/controller/ann-exceptions.adoc#webflux-ann-exceptionhandler-media[See equivalent in the Reactive stack]# + +In addition to exception types, `@ExceptionHandler` methods can also declare producible media types. +This allows to refine error responses depending on the media types requested by HTTP clients, typically in the "Accept" HTTP request header. + +Applications can declare producible media types directly on annotations, for the same exception type: + + +include-code::./MediaTypeController[tag=mediatype,indent=0] + +Here, methods handle the same exception type but will not be rejected as duplicates. +Instead, API clients requesting "application/json" will receive a JSON error, and browsers will get an HTML error view. +Each `@ExceptionHandler` annotation can declare several producible media types, +the content negotiation during the error handling phase will decide which content type will be used. + + [[mvc-ann-exceptionhandler-args]] == Method Arguments [.small]#xref:web/webflux/controller/ann-exceptions.adoc#webflux-ann-exceptionhandler-args[See equivalent in the Reactive stack]# diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-initbinder.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-initbinder.adoc index 9562ac0f7bcc..eca8278f3505 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-initbinder.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-initbinder.adoc @@ -27,7 +27,7 @@ have, with the notable exception of `@ModelAttribute`. Typically, such methods h ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller public class FormController { @@ -46,7 +46,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller class FormController { @@ -72,7 +72,7 @@ controller-specific `Formatter` implementations, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller public class FormController { @@ -89,7 +89,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller class FormController { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/cookievalue.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/cookievalue.adoc index d61859b5a15b..f56b52b967eb 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/cookievalue.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/cookievalue.adoc @@ -19,7 +19,7 @@ The following example shows how to get the cookie value: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/demo") public void handle(@CookieValue("JSESSIONID") String cookie) { <1> @@ -30,7 +30,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/demo") fun handle(@CookieValue("JSESSIONID") cookie: String) { // <1> diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/httpentity.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/httpentity.adoc index 024f99b14919..80e75a0e7d37 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/httpentity.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/httpentity.adoc @@ -10,7 +10,7 @@ container object that exposes request headers and body. The following listing sh ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") public void handle(HttpEntity entity) { @@ -20,7 +20,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") fun handle(entity: HttpEntity) { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/jackson.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/jackson.adoc index b0522fdb773c..b8c24d6641ac 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/jackson.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/jackson.adoc @@ -17,7 +17,7 @@ which allow rendering only a subset of all fields in an `Object`. To use it with ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RestController public class UserController { @@ -59,7 +59,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RestController class UserController { @@ -89,7 +89,7 @@ wrap the return value with `MappingJacksonValue` and use it to supply the serial ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RestController public class UserController { @@ -106,7 +106,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RestController class UserController { @@ -128,7 +128,7 @@ to the model, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller public class UserController extends AbstractController { @@ -144,7 +144,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller class UserController : AbstractController() { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/matrix-variables.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/matrix-variables.adoc index c96b15a35297..440bc376ed84 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/matrix-variables.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/matrix-variables.adoc @@ -22,7 +22,7 @@ The following example uses a matrix variable: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // GET /pets/42;q=11;r=22 @@ -36,7 +36,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // GET /pets/42;q=11;r=22 @@ -57,7 +57,7 @@ The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // GET /owners/42;q=11/pets/21;q=22 @@ -73,7 +73,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // GET /owners/42;q=11/pets/21;q=22 @@ -95,7 +95,7 @@ following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // GET /pets/42 @@ -108,7 +108,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // GET /pets/42 @@ -126,7 +126,7 @@ To get all matrix variables, you can use a `MultiValueMap`, as the following exa ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // GET /owners/42;q=11;r=12/pets/21;q=22;s=23 @@ -142,7 +142,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // GET /owners/42;q=11;r=12/pets/21;q=22;s=23 diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/modelattrib-method-args.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/modelattrib-method-args.adoc index 1ad2640d2abb..7a04b5ba7f13 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/modelattrib-method-args.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/modelattrib-method-args.adoc @@ -3,14 +3,14 @@ [.small]#xref:web/webflux/controller/ann-methods/modelattrib-method-args.adoc[See equivalent in the Reactive stack]# -The `@ModelAttribute` method parameter annotation binds request parameters onto a model -object. For example: +The `@ModelAttribute` method parameter annotation binds request parameters, URI path variables, +and request headers onto a model object. For example: [tabs] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") public String processSubmit(@ModelAttribute Pet pet) { // <1> @@ -21,7 +21,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") fun processSubmit(@ModelAttribute pet: Pet): String { // <1> @@ -31,7 +31,11 @@ fun processSubmit(@ModelAttribute pet: Pet): String { // <1> <1> Bind to an instance of `Pet`. ====== -The `Pet` instance may be: +Request parameters are a Servlet API concept that includes form data from the request body, +and query parameters. URI variables and headers are also included, but only if they don't +override request parameters with the same name. Dashes are stripped from header names. + +The `Pet` instance above may be: * Accessed from the model where it could have been added by a xref:web/webmvc/mvc-controller/ann-modelattrib-methods.adoc[@ModelAttribute method]. @@ -54,7 +58,7 @@ registered `Converter` that perhaps retrieves it from a persist ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PutMapping("/accounts/{account}") public String save(@ModelAttribute("account") Account account) { // <1> @@ -64,7 +68,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PutMapping("/accounts/{account}") fun save(@ModelAttribute("account") account: Account): String { // <1> @@ -89,7 +93,7 @@ When using constructor binding, you can customize request parameter names throug ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- class Account { @@ -102,7 +106,7 @@ Java:: ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class Account(@BindParam("first-name") val firstName: String) ---- @@ -112,6 +116,10 @@ NOTE: The `@BindParam` may also be placed on the fields that correspond to const parameters. While `@BindParam` is supported out of the box, you can also use a different annotation by setting a `DataBinder.NameResolver` on `DataBinder` +Constructor binding supports `List`, `Map`, and array arguments either converted from +a single string, for example, comma-separated list, or based on indexed keys such as +`accounts[2].name` or `account[KEY].name`. + In some cases, you may want access to a model attribute without data binding. For such cases, you can inject the `Model` into the controller and access it directly or, alternatively, set `@ModelAttribute(binding=false)`, as the following example shows: @@ -120,7 +128,7 @@ alternatively, set `@ModelAttribute(binding=false)`, as the following example sh ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ModelAttribute public AccountForm setUpForm() { @@ -142,7 +150,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ModelAttribute fun setUpForm(): AccountForm { @@ -171,7 +179,7 @@ in order to handle such errors in the controller method. For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") public String processSubmit(@ModelAttribute("pet") Pet pet, BindingResult result) { // <1> @@ -185,7 +193,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") fun processSubmit(@ModelAttribute("pet") pet: Pet, result: BindingResult): String { // <1> @@ -207,7 +215,7 @@ xref:web/webmvc/mvc-config/validation.adoc[Spring validation]. For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") public String processSubmit(@Valid @ModelAttribute("pet") Pet pet, BindingResult result) { // <1> @@ -221,7 +229,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/owners/{ownerId}/pets/{petId}/edit") fun processSubmit(@Valid @ModelAttribute("pet") pet: Pet, result: BindingResult): String { // <1> diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/multipart-forms.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/multipart-forms.adoc index 5e4addcb3a26..55c11bcbfa53 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/multipart-forms.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/multipart-forms.adoc @@ -12,7 +12,7 @@ file: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller public class FileUploadController { @@ -33,7 +33,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller class FileUploadController { @@ -72,7 +72,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- class MyForm { @@ -100,7 +100,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MyForm(val name: String, val file: MultipartFile, ...) @@ -153,7 +153,7 @@ xref:integration/rest-clients.adoc#rest-message-conversion[HttpMessageConverter] ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") public String handle(@RequestPart("meta-data") MetaData metadata, @@ -164,7 +164,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") fun handle(@RequestPart("meta-data") metadata: MetaData, @@ -185,7 +185,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") public String handle(@Valid @RequestPart("meta-data") MetaData metadata, Errors errors) { @@ -195,7 +195,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/") fun handle(@Valid @RequestPart("meta-data") metadata: MetaData, errors: Errors): String { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/redirecting-passing-data.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/redirecting-passing-data.adoc index 5ed5b89b4d15..645f2a25ae15 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/redirecting-passing-data.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/redirecting-passing-data.adoc @@ -30,7 +30,7 @@ through `Model` or `RedirectAttributes`. The following example shows how to defi ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/files/{path}") public String upload(...) { @@ -41,7 +41,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/files/{path}") fun upload(...): String { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestattrib.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestattrib.adoc index 110b415b4c08..76da7fff8828 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestattrib.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestattrib.adoc @@ -11,7 +11,7 @@ or `HandlerInterceptor`): ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/") public String handle(@RequestAttribute Client client) { // <1> @@ -22,7 +22,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/") fun handle(@RequestAttribute client: Client): String { // <1> diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestbody.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestbody.adoc index e7b22db93b6d..afb1509394d5 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestbody.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestbody.adoc @@ -11,7 +11,7 @@ The following example uses a `@RequestBody` argument: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") public void handle(@RequestBody Account account) { @@ -21,7 +21,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") fun handle(@RequestBody account: Account) { @@ -49,7 +49,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") public void handle(@Valid @RequestBody Account account, Errors errors) { @@ -59,7 +59,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/accounts") fun handle(@Valid @RequestBody account: Account, errors: Errors) { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestheader.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestheader.adoc index a63151aa66cf..99c1bcd0f6a0 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestheader.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestheader.adoc @@ -25,7 +25,7 @@ The following example gets the value of the `Accept-Encoding` and `Keep-Alive` h ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/demo") public void handle( @@ -39,7 +39,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/demo") fun handle( diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestparam.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestparam.adoc index fba75f9309df..839e0f57eab5 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestparam.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/requestparam.adoc @@ -12,7 +12,7 @@ The following example shows how to do so: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller @RequestMapping("/pets") @@ -35,7 +35,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.ui.set @@ -78,7 +78,7 @@ The following example shows how to do so with form data processing: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller @RequestMapping("/pets") @@ -96,7 +96,7 @@ Java:: ---- Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller @RequestMapping("/pets") diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/responsebody.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/responsebody.adoc index 4fb18b0002cf..8d2d827cee38 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/responsebody.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/responsebody.adoc @@ -12,7 +12,7 @@ The following listing shows an example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/accounts/{id}") @ResponseBody @@ -23,7 +23,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/accounts/{id}") @ResponseBody @@ -42,7 +42,7 @@ content of the provided resource to the response `OutputStream`. Note that the `InputStream` should be lazily retrieved by the `Resource` handle in order to reliably close it after it has been copied to the response. If you are using `InputStreamResource` for such a purpose, make sure to construct it with an on-demand `InputStreamSource` -(e.g. through a lambda expression that retrieves the actual `InputStream`). +(for example, through a lambda expression that retrieves the actual `InputStream`). You can use `@ResponseBody` with reactive types. See xref:web/webmvc/mvc-ann-async.adoc[Asynchronous Requests] and xref:web/webmvc/mvc-ann-async.adoc#mvc-ann-async-reactive-types[Reactive Types] for more details. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/responseentity.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/responseentity.adoc index 218ac995399e..f61a9878b1f5 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/responseentity.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/responseentity.adoc @@ -9,7 +9,7 @@ ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/something") public ResponseEntity handle() { @@ -21,7 +21,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/something") fun handle(): ResponseEntity { @@ -33,14 +33,14 @@ Kotlin:: ====== The body will usually be provided as a value object to be rendered to a corresponding -response representation (e.g. JSON) by one of the registered `HttpMessageConverters`. +response representation (for example, JSON) by one of the registered `HttpMessageConverters`. A `ResponseEntity` can be returned for file content, copying the `InputStream` content of the provided resource to the response `OutputStream`. Note that the `InputStream` should be lazily retrieved by the `Resource` handle in order to reliably close it after it has been copied to the response. If you are using `InputStreamResource` for such a purpose, make sure to construct it with an on-demand `InputStreamSource` -(e.g. through a lambda expression that retrieves the actual `InputStream`). Also, custom +(for example, through a lambda expression that retrieves the actual `InputStream`). Also, custom subclasses of `InputStreamResource` are only supported in combination with a custom `contentLength()` implementation which avoids consuming the stream for that purpose. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/return-types.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/return-types.adoc index 00d9f862428e..557de2db3ca2 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/return-types.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/return-types.adoc @@ -56,6 +56,10 @@ supported for all return values. | `ModelAndView` object | The view and model attributes to use and, optionally, a response status. +| `FragmentsRendering`, `Collection` +| For rendering one or more fragments each with its own view and model. + See xref:web/webmvc-view/mvc-fragments.adoc[HTML Fragments] for more details. + | `void` | A method with a `void` return type (or `null` return value) is considered to have fully handled the response if it also has a `ServletResponse`, an `OutputStream` argument, or @@ -89,9 +93,9 @@ supported for all return values. `ResponseEntity`. See xref:web/webmvc/mvc-ann-async.adoc[Asynchronous Requests] and xref:web/webmvc/mvc-ann-async.adoc#mvc-ann-async-http-streaming[HTTP Streaming]. | Reactor and other reactive types registered via `ReactiveAdapterRegistry` -| A single value type, e.g. `Mono`, is comparable to returning `DeferredResult`. - A multi-value type, e.g. `Flux`, may be treated as a stream depending on the requested - media type, e.g. "text/event-stream", "application/json+stream", or otherwise is +| A single value type, for example, `Mono`, is comparable to returning `DeferredResult`. + A multi-value type, for example, `Flux`, may be treated as a stream depending on the requested + media type, for example, "text/event-stream", "application/json+stream", or otherwise is collected to a List and rendered as a single value. See xref:web/webmvc/mvc-ann-async.adoc[Asynchronous Requests] and xref:web/webmvc/mvc-ann-async.adoc#mvc-ann-async-reactive-types[Reactive Types]. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/sessionattribute.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/sessionattribute.adoc index 726952dc5643..5e7754d3c4ef 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/sessionattribute.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/sessionattribute.adoc @@ -12,7 +12,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RequestMapping("/") public String handle(@SessionAttribute User user) { <1> @@ -23,7 +23,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RequestMapping("/") fun handle(@SessionAttribute user: User): String { // <1> diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/sessionattributes.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/sessionattributes.adoc index b2ea7ce9e33d..f9497c08bb44 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/sessionattributes.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/sessionattributes.adoc @@ -15,7 +15,7 @@ The following example uses the `@SessionAttributes` annotation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller @SessionAttributes("pet") // <1> @@ -27,7 +27,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller @SessionAttributes("pet") // <1> @@ -47,7 +47,7 @@ storage, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller @SessionAttributes("pet") // <1> @@ -70,7 +70,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller @SessionAttributes("pet") // <1> diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/typeconversion.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/typeconversion.adoc index fbaf33f89955..76f10b3d40e8 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/typeconversion.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-methods/typeconversion.adoc @@ -26,7 +26,7 @@ method intends to accept a null value as well, either declare your argument as ` or mark it as `required=false` in the corresponding `@RequestParam`, etc. annotation. This is a best practice and the recommended solution for regressions encountered in a 5.3 upgrade. -Alternatively, you may specifically handle e.g. the resulting `MissingPathVariableException` +Alternatively, you may specifically handle, for example, the resulting `MissingPathVariableException` in the case of a required `@PathVariable`. A null value after conversion will be treated like an empty original value, so the corresponding `Missing...Exception` variants will be thrown. ==== diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-modelattrib-methods.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-modelattrib-methods.adoc index c034514f2760..c0e28ba010f4 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-modelattrib-methods.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-modelattrib-methods.adoc @@ -28,7 +28,7 @@ The following example shows a `@ModelAttribute` method: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ModelAttribute public void populateModel(@RequestParam String number, Model model) { @@ -39,7 +39,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ModelAttribute fun populateModel(@RequestParam number: String, model: Model) { @@ -55,7 +55,7 @@ The following example adds only one attribute: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @ModelAttribute public Account addAccount(@RequestParam String number) { @@ -65,7 +65,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @ModelAttribute fun addAccount(@RequestParam number: String): Account { @@ -90,7 +90,7 @@ unless the return value is a `String` that would otherwise be interpreted as a v ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/accounts/{id}") @ModelAttribute("myAccount") @@ -102,7 +102,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/accounts/{id}") @ModelAttribute("myAccount") diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc index 0e6e08078282..a8cc9756dec7 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc @@ -42,7 +42,7 @@ The following example has type and method level mappings: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RestController @RequestMapping("/persons") @@ -63,7 +63,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RestController @RequestMapping("/persons") @@ -105,7 +105,7 @@ default from version 6.0. See xref:web/webmvc/mvc-config/path-matching.adoc[MVC customizations of path matching options. `PathPattern` supports the same pattern syntax as `AntPathMatcher`. In addition, it also -supports the capturing pattern, e.g. `+{*spring}+`, for matching 0 or more path segments +supports the capturing pattern, for example, `+{*spring}+`, for matching 0 or more path segments at the end of a path. `PathPattern` also restricts the use of `+**+` for matching multiple path segments such that it's only allowed at the end of a pattern. This eliminates many cases of ambiguity when choosing the best matching pattern for a given request. @@ -127,7 +127,7 @@ Captured URI variables can be accessed with `@PathVariable`. For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/owners/{ownerId}/pets/{petId}") public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) { @@ -137,7 +137,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/owners/{ownerId}/pets/{petId}") fun findPet(@PathVariable ownerId: Long, @PathVariable petId: Long): Pet { @@ -153,7 +153,7 @@ You can declare URI variables at the class and method levels, as the following e ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller @RequestMapping("/owners/{ownerId}") @@ -168,7 +168,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller @RequestMapping("/owners/{ownerId}") @@ -199,7 +199,7 @@ extracts the name, version, and file extension: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/{name:[a-z-]+}-{version:\\d\\.\\d\\.\\d}{ext:\\.[a-z]+}") public void handle(@PathVariable String name, @PathVariable String version, @PathVariable String ext) { @@ -209,7 +209,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/{name:[a-z-]+}-{version:\\d\\.\\d\\.\\d}{ext:\\.[a-z]+}") fun handle(@PathVariable name: String, @PathVariable version: String, @PathVariable ext: String) { @@ -272,7 +272,7 @@ To completely disable the use of path extensions in versions prior to 5.3, set t * `favorPathExtension(false)`, see xref:web/webmvc/mvc-config/content-negotiation.adoc[ContentNegotiationConfigurer] Having a way to request content types other than through the `"Accept"` header can still -be useful, e.g. when typing a URL in a browser. A safe alternative to path extensions is +be useful, for example, when typing a URL in a browser. A safe alternative to path extensions is to use the query parameter strategy. If you must use file extensions, consider restricting them to a list of explicitly registered extensions through the `mediaTypes` property of xref:web/webmvc/mvc-config/content-negotiation.adoc[ContentNegotiationConfigurer]. @@ -317,7 +317,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @PostMapping(path = "/pets", consumes = "application/json") // <1> public void addPet(@RequestBody Pet pet) { @@ -328,7 +328,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @PostMapping("/pets", consumes = ["application/json"]) // <1> fun addPet(@RequestBody pet: Pet) { @@ -360,7 +360,7 @@ content types that a controller method produces, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping(path = "/pets/{petId}", produces = "application/json") // <1> @ResponseBody @@ -372,7 +372,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/pets/{petId}", produces = ["application/json"]) // <1> @ResponseBody @@ -406,7 +406,7 @@ specific value (`myParam=myValue`). The following example shows how to test for ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping(path = "/pets/{petId}", params = "myParam=myValue") // <1> public void findPet(@PathVariable String petId) { @@ -417,7 +417,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/pets/{petId}", params = ["myParam=myValue"]) // <1> fun findPet(@PathVariable petId: String) { @@ -433,7 +433,7 @@ You can also use the same with request header conditions, as the following examp ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @GetMapping(path = "/pets/{petId}", headers = "myHeader=myValue") // <1> public void findPet(@PathVariable String petId) { @@ -444,7 +444,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @GetMapping("/pets/{petId}", headers = ["myHeader=myValue"]) // <1> fun findPet(@PathVariable petId: String) { @@ -519,7 +519,7 @@ under different URLs. The following example registers a handler method: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration public class MyConfig { @@ -544,7 +544,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration class MyConfig { @@ -587,7 +587,7 @@ For example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @HttpExchange("/persons") interface PersonService { @@ -615,7 +615,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @HttpExchange("/persons") interface PersonService { @@ -652,3 +652,8 @@ subset of the method parameters that `@RequestMapping` does. Notably, it exclude server-side specific parameter types. For details, see the list for xref:integration/rest-clients.adoc#rest-http-interface-method-parameters[@HttpExchange] and xref:web/webmvc/mvc-controller/ann-methods/arguments.adoc[@RequestMapping]. + +`@HttpExchange` also supports a `headers()` parameter which accepts `"name=value"`-like +pairs like in `@RequestMapping(headers={})` on the client side. On the server side, +this extends to the full syntax that +xref:#mvc-ann-requestmapping-params-and-headers[`@RequestMapping`] supports. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-validation.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-validation.adoc index 0cbe9c3d06a5..dd11e2edd769 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-validation.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-validation.adoc @@ -65,7 +65,7 @@ methods by controller method parameter type: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HandlerMethodValidationException ex = ... ; @@ -95,7 +95,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // HandlerMethodValidationException val ex diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann.adoc index b6495f54dd44..493d1d74d5f2 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann.adoc @@ -12,54 +12,7 @@ annotated class, indicating its role as a web component. To enable auto-detection of such `@Controller` beans, you can add component scanning to your Java configuration, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Configuration - @ComponentScan("org.example.web") - public class WebConfig { - - // ... - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Configuration - @ComponentScan("org.example.web") - class WebConfig { - - // ... - } ----- -====== - -The following example shows the XML configuration equivalent of the preceding example: - -[source,xml,indent=0,subs="verbatim,quotes"] ----- - - - - - - - - ----- +include-code::./WebConfiguration[tag=snippet,indent=0] `@RestController` is a xref:core/beans/classpath-scanning.adoc#beans-meta-annotations[composed annotation] that is itself meta-annotated with `@Controller` and `@ResponseBody` to indicate a controller whose diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet.adoc index 189ae02988e1..5205cec16d13 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet.adoc @@ -22,7 +22,7 @@ the `DispatcherServlet`, which is auto-detected by the Servlet container ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MyWebApplicationInitializer implements WebApplicationInitializer { @@ -44,7 +44,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MyWebApplicationInitializer : WebApplicationInitializer { @@ -111,7 +111,7 @@ the lifecycle of the Servlet container, Spring Boot uses Spring configuration to bootstrap itself and the embedded Servlet container. `Filter` and `Servlet` declarations are detected in Spring configuration and registered with the Servlet container. For more details, see the -{spring-boot-docs}/web.html#web.servlet.embedded-container[Spring Boot documentation]. +{spring-boot-docs-ref}/web/servlet.html#web.servlet.embedded-container[Spring Boot documentation]. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/container-config.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/container-config.adoc index a6a4755c0230..2ae4827d533b 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/container-config.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/container-config.adoc @@ -9,7 +9,7 @@ The following example registers a `DispatcherServlet`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- import org.springframework.web.WebApplicationInitializer; @@ -29,7 +29,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- import org.springframework.web.WebApplicationInitializer @@ -62,7 +62,7 @@ following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MyWebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { @@ -85,7 +85,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MyWebAppInitializer : AbstractAnnotationConfigDispatcherServletInitializer() { @@ -111,7 +111,7 @@ If you use XML-based Spring configuration, you should extend directly from ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MyWebAppInitializer extends AbstractDispatcherServletInitializer { @@ -136,7 +136,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MyWebAppInitializer : AbstractDispatcherServletInitializer() { @@ -165,7 +165,7 @@ following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MyWebAppInitializer extends AbstractDispatcherServletInitializer { @@ -181,7 +181,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MyWebAppInitializer : AbstractDispatcherServletInitializer() { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/context-hierarchy.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/context-hierarchy.adoc index 5e18f2e21eac..84f233adb498 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/context-hierarchy.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/context-hierarchy.adoc @@ -28,7 +28,7 @@ The following example configures a `WebApplicationContext` hierarchy: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MyWebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { @@ -51,7 +51,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MyWebAppInitializer : AbstractAnnotationConfigDispatcherServletInitializer() { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/exceptionhandlers.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/exceptionhandlers.adoc index 01cab7f3b7ed..21e062296def 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/exceptionhandlers.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/exceptionhandlers.adoc @@ -78,7 +78,7 @@ or to render a JSON response, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RestController public class ErrorController { @@ -95,7 +95,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RestController class ErrorController { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/logging.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/logging.adoc index 26e4193d81d0..24f0583fff44 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/logging.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/logging.adoc @@ -29,7 +29,7 @@ The following example shows how to do so by using Java configuration: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class MyInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { @@ -59,7 +59,7 @@ public class MyInitializer Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class MyInitializer : AbstractAnnotationConfigDispatcherServletInitializer() { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/multipart.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/multipart.adoc index d23200eb2a91..75dbbb1919b3 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/multipart.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/multipart.adoc @@ -32,7 +32,7 @@ The following example shows how to set a `MultipartConfigElement` on the Servlet ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- public class AppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { @@ -50,7 +50,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- class AppInitializer : AbstractAnnotationConfigDispatcherServletInitializer() { diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/viewresolver.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/viewresolver.adoc index 27daeeb49137..e7e5dad266f3 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/viewresolver.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-servlet/viewresolver.adoc @@ -47,7 +47,7 @@ The following table provides more details on the `ViewResolver` hierarchy: | Implementation of the `ViewResolver` interface that interprets a view name as a bean name in the current application context. This is a very flexible variant which allows for mixing and matching different view types based on distinct view names. - Each such `View` can be defined as a bean e.g. in XML or in configuration classes. + Each such `View` can be defined as a bean, for example, in XML or in configuration classes. |=== diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-uri-building.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-uri-building.adoc index 3e18dae861ad..4aba9d6ff6c2 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-uri-building.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-uri-building.adoc @@ -19,7 +19,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HttpServletRequest request = ... @@ -32,7 +32,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val request: HttpServletRequest = ... @@ -50,7 +50,7 @@ You can create URIs relative to the context path, as the following example shows ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HttpServletRequest request = ... @@ -64,7 +64,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val request: HttpServletRequest = ... @@ -84,7 +84,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- HttpServletRequest request = ... @@ -98,7 +98,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val request: HttpServletRequest = ... @@ -128,7 +128,7 @@ the following MVC controller allows for link creation: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @Controller @RequestMapping("/hotels/{hotel}") @@ -143,7 +143,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Controller @RequestMapping("/hotels/{hotel}") @@ -163,7 +163,7 @@ You can prepare a link by referring to the method by name, as the following exam ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- UriComponents uriComponents = MvcUriComponentsBuilder .fromMethodName(BookingController.class, "getBooking", 21).buildAndExpand(42); @@ -173,7 +173,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val uriComponents = MvcUriComponentsBuilder .fromMethodName(BookingController::class.java, "getBooking", 21).buildAndExpand(42) @@ -197,7 +197,7 @@ akin to mock testing through proxies to avoid referring to the controller method ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- UriComponents uriComponents = MvcUriComponentsBuilder .fromMethodCall(on(BookingController.class).getBooking(21)).buildAndExpand(42); @@ -207,7 +207,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val uriComponents = MvcUriComponentsBuilder .fromMethodCall(on(BookingController::class.java).getBooking(21)).buildAndExpand(42) @@ -240,7 +240,7 @@ following listing uses `withMethodCall`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- UriComponentsBuilder base = ServletUriComponentsBuilder.fromCurrentContextPath().path("/en"); MvcUriComponentsBuilder builder = MvcUriComponentsBuilder.relativeTo(base); @@ -251,7 +251,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val base = ServletUriComponentsBuilder.fromCurrentContextPath().path("/en") val builder = MvcUriComponentsBuilder.relativeTo(base) @@ -280,7 +280,7 @@ Consider the following example: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- @RequestMapping("/people/{id}/addresses") public class PersonAddressController { @@ -292,7 +292,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- @RequestMapping("/people/{id}/addresses") class PersonAddressController { diff --git a/framework-docs/modules/ROOT/pages/web/websocket.adoc b/framework-docs/modules/ROOT/pages/web/websocket.adoc index 726c9c2de3ed..f917e7e09390 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket.adoc @@ -1,6 +1,7 @@ [[websocket]] = WebSockets :page-section-summary-toc: 1 + [.small]#xref:web/webflux-websocket.adoc[See equivalent in the Reactive stack]# This part of the reference documentation covers support for Servlet stack, WebSocket diff --git a/framework-docs/modules/ROOT/pages/web/websocket/fallback.adoc b/framework-docs/modules/ROOT/pages/web/websocket/fallback.adoc index 72bcaacd413e..c79fd0a70f86 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/fallback.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/fallback.adoc @@ -82,49 +82,9 @@ https://sockjs.github.io/sockjs-protocol/sockjs-protocol-0.3.3.html[narrated tes [[websocket-fallback-sockjs-enable]] == Enabling SockJS -You can enable SockJS through Java configuration, as the following example shows: +You can enable SockJS through configuration, as the following example shows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocket - public class WebSocketConfig implements WebSocketConfigurer { - - @Override - public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(myHandler(), "/myHandler").withSockJS(); - } - - @Bean - public WebSocketHandler myHandler() { - return new MyHandler(); - } - - } ----- - -The following example shows the XML configuration equivalent of the preceding example: - -[source,xml,indent=0,subs="verbatim,quotes,attributes"] ----- - - - - - - - - - - ----- +include-code::./WebSocketConfiguration[tag=snippet,indent=0] The preceding example is for use in Spring MVC applications and should be included in the configuration of a xref:web/webmvc/mvc-servlet.adoc[`DispatcherServlet`]. However, Spring's WebSocket diff --git a/framework-docs/modules/ROOT/pages/web/websocket/server.adoc b/framework-docs/modules/ROOT/pages/web/websocket/server.adoc index 30d18ba93cbe..13c005fedf34 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/server.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/server.adoc @@ -16,69 +16,12 @@ Creating a WebSocket server is as simple as implementing `WebSocketHandler` or, likely, extending either `TextWebSocketHandler` or `BinaryWebSocketHandler`. The following example uses `TextWebSocketHandler`: -[source,java,indent=0,subs="verbatim,quotes"] ----- - import org.springframework.web.socket.WebSocketHandler; - import org.springframework.web.socket.WebSocketSession; - import org.springframework.web.socket.TextMessage; - - public class MyHandler extends TextWebSocketHandler { - - @Override - public void handleTextMessage(WebSocketSession session, TextMessage message) { - // ... - } - - } ----- +include-code::./MyHandler[tag=snippet,indent=0] -There is dedicated WebSocket Java configuration and XML namespace support for mapping the preceding +There is dedicated WebSocket programmatic configuration and XML namespace support for mapping the preceding WebSocket handler to a specific URL, as the following example shows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - import org.springframework.web.socket.config.annotation.EnableWebSocket; - import org.springframework.web.socket.config.annotation.WebSocketConfigurer; - import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; - - @Configuration - @EnableWebSocket - public class WebSocketConfig implements WebSocketConfigurer { - - @Override - public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(myHandler(), "/myHandler"); - } - - @Bean - public WebSocketHandler myHandler() { - return new MyHandler(); - } - - } ----- - -The following example shows the XML configuration equivalent of the preceding example: - -[source,xml,indent=0,subs="verbatim,quotes,attributes"] ----- - - - - - - - - - ----- +include-code::./WebSocketConfiguration[tag=snippet,indent=0] The preceding example is for use in Spring MVC applications and should be included in the configuration of a xref:web/webmvc/mvc-servlet.adoc[`DispatcherServlet`]. However, Spring's @@ -86,7 +29,7 @@ WebSocket support does not depend on Spring MVC. It is relatively simple to integrate a `WebSocketHandler` into other HTTP-serving environments with the help of {spring-framework-api}/web/socket/server/support/WebSocketHttpRequestHandler.html[`WebSocketHttpRequestHandler`]. -When using the `WebSocketHandler` API directly vs indirectly, e.g. through the +When using the `WebSocketHandler` API directly vs indirectly, for example, through the xref:web/websocket/stomp.adoc[STOMP] messaging, the application must synchronize the sending of messages since the underlying standard WebSocket session (JSR-356) does not allow concurrent sending. One option is to wrap the `WebSocketSession` with @@ -104,45 +47,7 @@ You can use such an interceptor to preclude the handshake or to make any attribu available to the `WebSocketSession`. The following example uses a built-in interceptor to pass HTTP session attributes to the WebSocket session: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocket - public class WebSocketConfig implements WebSocketConfigurer { - - @Override - public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(new MyHandler(), "/myHandler") - .addInterceptors(new HttpSessionHandshakeInterceptor()); - } - - } ----- - -The following example shows the XML configuration equivalent of the preceding example: - -[source,xml,indent=0,subs="verbatim,quotes,attributes"] ----- - - - - - - - - - - - - ----- +include-code::./WebSocketConfiguration[tag=snippet,indent=0] A more advanced option is to extend the `DefaultHandshakeHandler` that performs the steps of the WebSocket handshake, including validating the client origin, @@ -236,69 +141,17 @@ You can configure of the underlying WebSocket server such as input message buffe idle timeout, and more. For Jakarta WebSocket servers, you can add a `ServletServerContainerFactoryBean` to your -Java configuration. For example: - -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Bean - public ServletServerContainerFactoryBean createWebSocketContainer() { - ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); - container.setMaxTextMessageBufferSize(8192); - container.setMaxBinaryMessageBufferSize(8192); - return container; - } ----- - -Or to your XML configuration: +configuration. For example: -[source,xml,indent=0,subs="verbatim,quotes,attributes"] ----- - - - - - - - - ----- +include-code::./WebSocketConfiguration[tag=snippet,indent=0] NOTE: For client Jakarta WebSocket configuration, use -ContainerProvider.getWebSocketContainer() in Java configuration, or +ContainerProvider.getWebSocketContainer() in programmatic configuration, or `WebSocketContainerFactoryBean` in XML. -For Jetty, you can supply a `Consumer` callback to configure the WebSocket server: +For Jetty, you can supply a callback to configure the WebSocket server: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocket - public class WebSocketConfig implements WebSocketConfigurer { - - @Override - public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(echoWebSocketHandler(), "/echo").setHandshakeHandler(handshakeHandler()); - } - - @Bean - public DefaultHandshakeHandler handshakeHandler() { - JettyRequestUpgradeStrategy strategy = new JettyRequestUpgradeStrategy(); - strategy.addWebSocketConfigurer(configurable -> { - policy.setInputBufferSize(8192); - policy.setIdleTimeout(600000); - }); - return new DefaultHandshakeHandler(strategy); - } - - } ----- +include-code::./JettyWebSocketConfiguration[tag=snippet,indent=0] TIP: When using STOMP over WebSocket, you will also need to configure xref:web/websocket/stomp/server-config.adoc[STOMP WebSocket transport] @@ -331,51 +184,4 @@ The three possible behaviors are: You can configure WebSocket and SockJS allowed origins, as the following example shows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - import org.springframework.web.socket.config.annotation.EnableWebSocket; - import org.springframework.web.socket.config.annotation.WebSocketConfigurer; - import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; - - @Configuration - @EnableWebSocket - public class WebSocketConfig implements WebSocketConfigurer { - - @Override - public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(myHandler(), "/myHandler").setAllowedOrigins("https://mydomain.com"); - } - - @Bean - public WebSocketHandler myHandler() { - return new MyHandler(); - } - - } ----- - -The following example shows the XML configuration equivalent of the preceding example: - -[source,xml,indent=0,subs="verbatim,quotes,attributes"] ----- - - - - - - - - - ----- - - - - +include-code::./WebSocketConfiguration[tag=snippet,indent=0] diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/authentication-token-based.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/authentication-token-based.adoc index 03745ca29b5d..b65811e74b8a 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/authentication-token-based.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/authentication-token-based.adoc @@ -40,29 +40,7 @@ the user header on the CONNECT `Message`. Spring notes and saves the authenticat user and associate it with subsequent STOMP messages on the same session. The following example shows how to register a custom authentication interceptor: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocketMessageBroker - public class MyConfig implements WebSocketMessageBrokerConfigurer { - - @Override - public void configureClientInboundChannel(ChannelRegistration registration) { - registration.interceptors(new ChannelInterceptor() { - @Override - public Message preSend(Message message, MessageChannel channel) { - StompHeaderAccessor accessor = - MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); - if (StompCommand.CONNECT.equals(accessor.getCommand())) { - Authentication user = ... ; // access authentication header(s) - accessor.setUser(user); - } - return message; - } - }); - } - } ----- +include-code::./WebSocketConfiguration[tag=snippet,indent=0] Also, note that, when you use Spring Security's authorization for messages, at present, you need to ensure that the authentication `ChannelInterceptor` config is ordered diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/client.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/client.adoc index 1eae09021206..ba205223a86b 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/client.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/client.adoc @@ -105,5 +105,20 @@ it handle ERROR frames in addition to the `handleException` callback for exceptions from the handling of messages and `handleTransportError` for transport-level errors including `ConnectionLostException`. +You can use the `inboundMessageSizeLimit` and `outboundMessageSizeLimit` properties of +`WebSocketStompClient` to limit the maximum size of inbound and outbound WebSocket +messages. When an outbound STOMP message exceeds the limit, it is split into partial frames, +which the receiver would have to reassemble. By default, there is no size limit for outbound +messages. When an inbound STOMP message size exceeds the configured limit, a +`StompConversionException` is thrown. The default size limit for inbound messages is `64KB`. + +[source,java,indent=0,subs="verbatim,quotes"] +---- + WebSocketClient webSocketClient = new StandardWebSocketClient(); + WebSocketStompClient stompClient = new WebSocketStompClient(webSocketClient); + stompClient.setInboundMessageSizeLimit(64 * 1024); // 64KB + stompClient.setOutboundMessageSizeLimit(64 * 1024); // 64KB +---- + diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/configuration-performance.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/configuration-performance.adoc index 045d36398b3f..cc5df948025f 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/configuration-performance.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/configuration-performance.adoc @@ -62,42 +62,7 @@ documentation of the XML schema for important additional details. The following example shows a possible configuration: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocketMessageBroker - public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - @Override - public void configureWebSocketTransport(WebSocketTransportRegistration registration) { - registration.setSendTimeLimit(15 * 1000).setSendBufferSizeLimit(512 * 1024); - } - - // ... - - } ----- - -The following example shows the XML configuration equivalent of the preceding example: - -[source,xml,indent=0,subs="verbatim,quotes,attributes"] ----- - - - - - - - - ----- +include-code::./WebSocketConfiguration[tag=snippet,indent=0] You can also use the WebSocket transport configuration shown earlier to configure the maximum allowed size for incoming STOMP messages. In theory, a WebSocket @@ -115,42 +80,7 @@ minimum. The following example shows one possible configuration: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocketMessageBroker - public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - @Override - public void configureWebSocketTransport(WebSocketTransportRegistration registration) { - registration.setMessageSizeLimit(128 * 1024); - } - - // ... - - } ----- - -The following example shows the XML configuration equivalent of the preceding example: - -[source,xml,indent=0,subs="verbatim,quotes,attributes"] ----- - - - - - - - - ----- +include-code::./MessageSizeLimitWebSocketConfiguration[tag=snippet,indent=0] An important point about scaling involves using multiple application instances. Currently, you cannot do that with the simple broker. diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/destination-separator.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/destination-separator.adoc index 866b6d81e07a..0c81e2c2b861 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/destination-separator.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/destination-separator.adoc @@ -6,65 +6,14 @@ When messages are routed to `@MessageMapping` methods, they are matched with This is a good convention in web applications and similar to HTTP URLs. However, if you are more used to messaging conventions, you can switch to using dot (`.`) as the separator. -The following example shows how to do so in Java configuration: +The following example shows how to do so: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocketMessageBroker - public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - // ... - - @Override - public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.setPathMatcher(new AntPathMatcher(".")); - registry.enableStompBrokerRelay("/queue", "/topic"); - registry.setApplicationDestinationPrefixes("/app"); - } - } ----- - -The following example shows the XML configuration equivalent of the preceding example: - -[source,xml,indent=0,subs="verbatim,quotes,attributes"] ----- - - - - - - - - - - - - ----- +include-code::./WebSocketConfiguration[tag=snippet,indent=0] After that, a controller can use a dot (`.`) as the separator in `@MessageMapping` methods, as the following example shows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Controller - @MessageMapping("red") - public class RedController { - - @MessageMapping("blue.{green}") - public void handleGreen(@DestinationVariable String green) { - // ... - } - } ----- +include-code::./RedController[tag=snippet,indent=0] The client can now send a message to `/app/red.blue.green123`. diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/enable.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/enable.adoc index 433043984338..4301ba970868 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/enable.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/enable.adoc @@ -5,56 +5,7 @@ STOMP over WebSocket support is available in the `spring-messaging` and `spring-websocket` modules. Once you have those dependencies, you can expose a STOMP endpoint over WebSocket, as the following example shows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; - import org.springframework.web.socket.config.annotation.StompEndpointRegistry; - - @Configuration - @EnableWebSocketMessageBroker - public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - @Override - public void registerStompEndpoints(StompEndpointRegistry registry) { - registry.addEndpoint("/portfolio"); // <1> - } - - @Override - public void configureMessageBroker(MessageBrokerRegistry config) { - config.setApplicationDestinationPrefixes("/app"); // <2> - config.enableSimpleBroker("/topic", "/queue"); // <3> - } - } ----- - -<1> `/portfolio` is the HTTP URL for the endpoint to which a WebSocket (or SockJS) -client needs to connect for the WebSocket handshake. -<2> STOMP messages whose destination header begins with `/app` are routed to -`@MessageMapping` methods in `@Controller` classes. -<3> Use the built-in message broker for subscriptions and broadcasting and -route messages whose destination header begins with `/topic` or `/queue` to the broker. - - -The following example shows the XML configuration equivalent of the preceding example: - -[source,xml,indent=0,subs="verbatim,quotes,attributes"] ----- - - - - - - - - ----- +include-code::./WebSocketConfiguration[tag=snippet,indent=0] NOTE: For the built-in simple broker, the `/topic` and `/queue` prefixes do not have any special meaning. They are merely a convention to differentiate between pub-sub versus point-to-point diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/handle-broker-relay-configure.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/handle-broker-relay-configure.adoc index 853732b6000d..f13d532367fe 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/handle-broker-relay-configure.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/handle-broker-relay-configure.adoc @@ -36,27 +36,7 @@ connectivity is lost, to the same host and port. If you wish to supply multiple on each attempt to connect, you can configure a supplier of addresses, instead of a fixed host and port. The following example shows how to do that: -[source,java,indent=0,subs="verbatim,quotes"] ----- -@Configuration -@EnableWebSocketMessageBroker -public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer { - - // ... - - @Override - public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.enableStompBrokerRelay("/queue/", "/topic/").setTcpClient(createTcpClient()); - registry.setApplicationDestinationPrefixes("/app"); - } - - private ReactorNettyTcpClient createTcpClient() { - return new ReactorNettyTcpClient<>( - client -> client.addressSupplier(() -> ... ), - new StompReactorNettyCodec()); - } -} ----- +include-code::./WebSocketConfiguration[tag=snippet,indent=0] You can also configure the STOMP broker relay with a `virtualHost` property. The value of this property is set as the `host` header of every `CONNECT` frame diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/handle-broker-relay.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/handle-broker-relay.adoc index 8a59bd83f937..fd0ddcec2267 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/handle-broker-relay.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/handle-broker-relay.adoc @@ -15,48 +15,7 @@ and run it with STOMP support enabled. Then you can enable the STOMP broker rela The following example configuration enables a full-featured broker: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocketMessageBroker - public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - @Override - public void registerStompEndpoints(StompEndpointRegistry registry) { - registry.addEndpoint("/portfolio").withSockJS(); - } - - @Override - public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.enableStompBrokerRelay("/topic", "/queue"); - registry.setApplicationDestinationPrefixes("/app"); - } - - } ----- - -The following example shows the XML configuration equivalent of the preceding example: - -[source,xml,indent=0,subs="verbatim,quotes,attributes"] ----- - - - - - - - - - - ----- +include-code::./WebSocketConfiguration[tag=snippet,indent=0] The STOMP broker relay in the preceding configuration is a Spring {spring-framework-api}/messaging/MessageHandler.html[`MessageHandler`] diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/handle-simple-broker.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/handle-simple-broker.adoc index cbedc368c2c2..15efa72b1015 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/handle-simple-broker.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/handle-simple-broker.adoc @@ -17,29 +17,4 @@ declared in the built-in WebSocket configuration, however, you'll need `@Lazy` t a cycle between the built-in WebSocket configuration and your `WebSocketMessageBrokerConfigurer`. For example: -[source,java,indent=0,subs="verbatim,quotes"] ----- -@Configuration -@EnableWebSocketMessageBroker -public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - private TaskScheduler messageBrokerTaskScheduler; - - @Autowired - public void setMessageBrokerTaskScheduler(@Lazy TaskScheduler taskScheduler) { - this.messageBrokerTaskScheduler = taskScheduler; - } - - @Override - public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.enableSimpleBroker("/queue/", "/topic/") - .setHeartbeatValue(new long[] {10000, 20000}) - .setTaskScheduler(this.messageBrokerTaskScheduler); - - // ... - } -} ----- - - - +include-code::./WebSocketConfiguration[tag=snippet,indent=0] diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/interceptors.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/interceptors.adoc index 7a21a1b8d40d..9bdab9835166 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/interceptors.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/interceptors.adoc @@ -6,35 +6,12 @@ of a STOMP connection but not for every client message. Applications can also re `ChannelInterceptor` to intercept any message and in any part of the processing chain. The following example shows how to intercept inbound messages from clients: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocketMessageBroker - public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - @Override - public void configureClientInboundChannel(ChannelRegistration registration) { - registration.interceptors(new MyChannelInterceptor()); - } - } ----- +include-code::./WebSocketConfiguration[tag=snippet,indent=0] A custom `ChannelInterceptor` can use `StompHeaderAccessor` or `SimpMessageHeaderAccessor` to access information about the message, as the following example shows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - public class MyChannelInterceptor implements ChannelInterceptor { - - @Override - public Message preSend(Message message, MessageChannel channel) { - StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); - StompCommand command = accessor.getStompCommand(); - // ... - return message; - } - } ----- +include-code::./MyChannelInterceptor[tag=snippet,indent=0] Applications can also implement `ExecutorChannelInterceptor`, which is a sub-interface of `ChannelInterceptor` with callbacks in the thread in which the messages are handled. diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/message-flow.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/message-flow.adoc index 1c4e3adb52c0..aee0cd9adc8b 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/message-flow.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/message-flow.adoc @@ -60,33 +60,9 @@ to broadcast to subscribed clients. We can trace the flow through a simple example. Consider the following example, which sets up a server: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocketMessageBroker - public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - @Override - public void registerStompEndpoints(StompEndpointRegistry registry) { - registry.addEndpoint("/portfolio"); - } - - @Override - public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.setApplicationDestinationPrefixes("/app"); - registry.enableSimpleBroker("/topic"); - } - } - - @Controller - public class GreetingController { - - @MessageMapping("/greeting") - public String handle(String greeting) { - return "[" + getTimestamp() + ": " + greeting; - } - } ----- +include-code::./WebSocketConfiguration[tag=snippet,indent=0] + +include-code::./GreetingController[tag=snippet,indent=0] The preceding example supports the following flow: diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/ordered-messages.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/ordered-messages.adoc index 89e60da926c2..d56552286f2d 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/ordered-messages.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/ordered-messages.adoc @@ -8,40 +8,7 @@ not match the exact order of publication. To enable ordered publishing, set the `setPreservePublishOrder` flag as follows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocketMessageBroker - public class MyConfig implements WebSocketMessageBrokerConfigurer { - - @Override - protected void configureMessageBroker(MessageBrokerRegistry registry) { - // ... - registry.setPreservePublishOrder(true); - } - - } ----- - -The following example shows the XML configuration equivalent of the preceding example: - -[source,xml,indent=0,subs="verbatim,quotes,attributes"] ----- - - - - - - - ----- +include-code::./PublishOrderWebSocketConfiguration[tag=snippet,indent=0] When the flag is set, messages within the same client session are published to the `clientOutboundChannel` one at a time, so that the order of publication is guaranteed. @@ -52,17 +19,6 @@ from where they are handled according to their destination prefix. As the channe a `ThreadPoolExecutor`, messages are processed in different threads, and the resulting sequence of handling may not match the exact order in which they were received. -To enable ordered publishing, set the `setPreserveReceiveOrder` flag as follows: - -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocketMessageBroker - public class MyConfig implements WebSocketMessageBrokerConfigurer { +To enable ordered receiving, set the `setPreserveReceiveOrder` flag as follows: - @Override - public void registerStompEndpoints(StompEndpointRegistry registry) { - registry.setPreserveReceiveOrder(true); - } - } ----- +include-code::./ReceiveOrderWebSocketConfiguration[tag=snippet,indent=0] diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/server-config.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/server-config.adoc index 7608bb8635bb..5903c07051bb 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/server-config.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/server-config.adoc @@ -10,43 +10,9 @@ under the WebSocket section. For Jetty WebSocket servers, customize the `JettyRequestUpgradeStrategy` as follows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocketMessageBroker - public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - @Override - public void registerStompEndpoints(StompEndpointRegistry registry) { - registry.addEndpoint("/portfolio").setHandshakeHandler(handshakeHandler()); - } - - @Bean - public DefaultHandshakeHandler handshakeHandler() { - JettyRequestUpgradeStrategy strategy = new JettyRequestUpgradeStrategy(); - strategy.addWebSocketConfigurer(configurable -> { - policy.setInputBufferSize(4 * 8192); - policy.setIdleTimeout(600000); - }); - return new DefaultHandshakeHandler(strategy); - } - } ----- +include-code::./JettyWebSocketConfiguration[tag=snippet,indent=0] In addition to WebSocket server properties, there are also STOMP WebSocket transport properties to customize as follows: -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Configuration - @EnableWebSocketMessageBroker - public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - @Override - public void configureWebSocketTransport(WebSocketTransportRegistration registry) { - registry.setMessageSizeLimit(4 * 8192); - registry.setTimeToFirstMessage(30000); - } - - } ----- +include-code::./WebSocketConfiguration[tag=snippet,indent=0] diff --git a/framework-docs/modules/ROOT/partials/web/forwarded-headers.adoc b/framework-docs/modules/ROOT/partials/web/forwarded-headers.adoc index 9ba46a5f832a..a56c0d1893ed 100644 --- a/framework-docs/modules/ROOT/partials/web/forwarded-headers.adoc +++ b/framework-docs/modules/ROOT/partials/web/forwarded-headers.adoc @@ -38,7 +38,7 @@ to inform the server that the original port was `443`. ==== X-Forwarded-Proto While not standard, https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto[`X-Forwarded-Proto: (https|http)`] -is a de-facto standard header that is used to communicate the original protocol (e.g. https / https) +is a de-facto standard header that is used to communicate the original protocol (for example, https / https) to a downstream server. For example, if a request of `https://example.com/resource` is sent to a proxy which forwards the request to `http://localhost:8080/resource`, then a header of `X-Forwarded-Proto: https` can be sent to inform the server that the original protocol was `https`. @@ -48,7 +48,7 @@ a proxy which forwards the request to `http://localhost:8080/resource`, then a h ==== X-Forwarded-Ssl While not standard, `X-Forwarded-Ssl: (on|off)` is a de-facto standard header that is used to communicate the -original protocol (e.g. https / https) to a downstream server. For example, if a request of +original protocol (for example, https / https) to a downstream server. For example, if a request of `https://example.com/resource` is sent to a proxy which forwards the request to `http://localhost:8080/resource`, then a header of `X-Forwarded-Ssl: on` to inform the server that the original protocol was `https`. @@ -103,7 +103,7 @@ applications on the same server. However, this should not be visible in URL path the public API where applications may use different subdomains that provides benefits such as: -* Added security, e.g. same origin policy +* Added security, for example, same origin policy * Independent scaling of applications (different domain points to different IP address) ==== diff --git a/framework-docs/modules/ROOT/partials/web/web-uris.adoc b/framework-docs/modules/ROOT/partials/web/web-uris.adoc index 9b7df3674d8a..8ba08ac1cbdd 100644 --- a/framework-docs/modules/ROOT/partials/web/web-uris.adoc +++ b/framework-docs/modules/ROOT/partials/web/web-uris.adoc @@ -8,7 +8,7 @@ ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- UriComponents uriComponents = UriComponentsBuilder .fromUriString("https://example.com/hotels/{hotel}") // <1> @@ -26,7 +26,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val uriComponents = UriComponentsBuilder .fromUriString("https://example.com/hotels/{hotel}") // <1> @@ -50,7 +50,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- URI uri = UriComponentsBuilder .fromUriString("https://example.com/hotels/{hotel}") @@ -62,7 +62,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val uri = UriComponentsBuilder .fromUriString("https://example.com/hotels/{hotel}") @@ -80,7 +80,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- URI uri = UriComponentsBuilder .fromUriString("https://example.com/hotels/{hotel}") @@ -90,7 +90,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val uri = UriComponentsBuilder .fromUriString("https://example.com/hotels/{hotel}") @@ -105,7 +105,7 @@ You can shorten it further still with a full URI template, as the following exam ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- URI uri = UriComponentsBuilder .fromUriString("https://example.com/hotels/{hotel}?q={q}") @@ -114,7 +114,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val uri = UriComponentsBuilder .fromUriString("https://example.com/hotels/{hotel}?q={q}") @@ -144,7 +144,7 @@ The following example shows how to configure a `RestTemplate`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // import org.springframework.web.util.DefaultUriBuilderFactory.EncodingMode; @@ -158,7 +158,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // import org.springframework.web.util.DefaultUriBuilderFactory.EncodingMode @@ -177,7 +177,7 @@ The following example configures a `WebClient`: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- // import org.springframework.web.util.DefaultUriBuilderFactory.EncodingMode; @@ -190,7 +190,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- // import org.springframework.web.util.DefaultUriBuilderFactory.EncodingMode @@ -210,7 +210,7 @@ that holds configuration and preferences, as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- String baseUrl = "https://example.com"; DefaultUriBuilderFactory uriBuilderFactory = new DefaultUriBuilderFactory(baseUrl); @@ -222,7 +222,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val baseUrl = "https://example.com" val uriBuilderFactory = DefaultUriBuilderFactory(baseUrl) @@ -234,6 +234,34 @@ Kotlin:: ====== +[[uri-parsing]] += URI Parsing +[.small]#Spring MVC and Spring WebFlux# + +`UriComponentsBuilder` supports two URI parser types: + +1. RFC parser -- this parser type expects URI strings to conform to RFC 3986 syntax, +and treats deviations from the syntax as illegal. + +2. WhatWG parser -- this parser is based on the +https://github.com/web-platform-tests/wpt/tree/master/url[URL parsing algorithm] in the +https://url.spec.whatwg.org[WhatWG URL Living standard]. It provides lenient handling of +a wide range of cases of unexpected input. Browsers implement this in order to handle +leniently user typed URL's. For more details, see the URL Living Standard and URL parsing +https://github.com/web-platform-tests/wpt/tree/master/url[test cases]. + +By default, `RestClient`, `WebClient`, and `RestTemplate` use the RFC parser type, and +expect applications to provide with URL templates that conform to RFC syntax. To change +that you can customize the `UriBuilderFactory` on any of the clients. + +Applications and frameworks may further rely on `UriComponentsBuilder` for their own needs +to parse user provided URL's in order to inspect and possibly validated URI components +such as the scheme, host, port, path, and query. Such components can decide to use the +WhatWG parser type in order to handle URL's more leniently, and to align with the way +browsers parse URI's, in case of a redirect to the input URL or if it is included in a +response to a browser. + + [[uri-encoding]] = URI Encoding [.small]#Spring MVC and Spring WebFlux# @@ -264,7 +292,7 @@ The following example uses the first option: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- URI uri = UriComponentsBuilder.fromPath("/hotel list/{city}") .queryParam("q", "{q}") @@ -277,7 +305,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val uri = UriComponentsBuilder.fromPath("/hotel list/{city}") .queryParam("q", "{q}") @@ -296,7 +324,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- URI uri = UriComponentsBuilder.fromPath("/hotel list/{city}") .queryParam("q", "{q}") @@ -305,7 +333,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val uri = UriComponentsBuilder.fromPath("/hotel list/{city}") .queryParam("q", "{q}") @@ -319,7 +347,7 @@ You can shorten it further still with a full URI template, as the following exam ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- URI uri = UriComponentsBuilder.fromUriString("/hotel list/{city}?q={q}") .build("New York", "foo+bar"); @@ -327,7 +355,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val uri = UriComponentsBuilder.fromUriString("/hotel list/{city}?q={q}") .build("New York", "foo+bar") @@ -342,7 +370,7 @@ as the following example shows: ====== Java:: + -[source,java,indent=0,subs="verbatim,quotes",role="primary"] +[source,java,indent=0,subs="verbatim,quotes"] ---- String baseUrl = "https://example.com"; DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(baseUrl) @@ -358,7 +386,7 @@ Java:: Kotlin:: + -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +[source,kotlin,indent=0,subs="verbatim,quotes"] ---- val baseUrl = "https://example.com" val factory = DefaultUriBuilderFactory(baseUrl).apply { diff --git a/framework-docs/modules/ROOT/partials/web/websocket-intro.adoc b/framework-docs/modules/ROOT/partials/web/websocket-intro.adoc index 8598a214be91..60c972a4147c 100644 --- a/framework-docs/modules/ROOT/partials/web/websocket-intro.adoc +++ b/framework-docs/modules/ROOT/partials/web/websocket-intro.adoc @@ -46,7 +46,7 @@ A complete introduction of how WebSockets work is beyond the scope of this docum See RFC 6455, the WebSocket chapter of HTML5, or any of the many introductions and tutorials on the Web. -Note that, if a WebSocket server is running behind a web server (e.g. nginx), you +Note that, if a WebSocket server is running behind a web server (for example, nginx), you likely need to configure it to pass WebSocket upgrade requests on to the WebSocket server. Likewise, if the application runs in a cloud environment, check the instructions of the cloud provider related to WebSocket support. diff --git a/framework-docs/package.json b/framework-docs/package.json index c3570e2f8a62..be6e5f8c3894 100644 --- a/framework-docs/package.json +++ b/framework-docs/package.json @@ -4,7 +4,7 @@ "@antora/atlas-extension": "1.0.0-alpha.2", "@antora/collector-extension": "1.0.0-alpha.3", "@asciidoctor/tabs": "1.0.0-beta.6", - "@springio/antora-extensions": "1.11.1", + "@springio/antora-extensions": "1.14.2", "@springio/asciidoctor-extensions": "1.0.0-alpha.10" } } diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aop/aopajltwspring/ApplicationConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/aop/aopajltwspring/ApplicationConfiguration.java new file mode 100644 index 000000000000..4f1456b36e19 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aop/aopajltwspring/ApplicationConfiguration.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.aopajltwspring; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableLoadTimeWeaving; + +// tag::snippet[] +@Configuration +@EnableLoadTimeWeaving +public class ApplicationConfiguration { +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aop/aopajltwspring/CustomWeaverConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/aop/aopajltwspring/CustomWeaverConfiguration.java new file mode 100644 index 000000000000..0a51c937e296 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aop/aopajltwspring/CustomWeaverConfiguration.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.aopajltwspring; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableLoadTimeWeaving; +import org.springframework.context.annotation.LoadTimeWeavingConfigurer; +import org.springframework.instrument.classloading.LoadTimeWeaver; +import org.springframework.instrument.classloading.ReflectiveLoadTimeWeaver; + +// tag::snippet[] +@Configuration +@EnableLoadTimeWeaving +public class CustomWeaverConfiguration implements LoadTimeWeavingConfigurer { + + @Override + public LoadTimeWeaver getLoadTimeWeaver() { + return new ReflectiveLoadTimeWeaver(); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aop/aopatconfigurable/ApplicationConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/aop/aopatconfigurable/ApplicationConfiguration.java new file mode 100644 index 000000000000..a7b6bb6496d2 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aop/aopatconfigurable/ApplicationConfiguration.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.aopatconfigurable; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.aspectj.EnableSpringConfigured; + +// tag::snippet[] +@Configuration +@EnableSpringConfigured +public class ApplicationConfiguration { +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopaspectjsupport/ApplicationConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopaspectjsupport/ApplicationConfiguration.java new file mode 100644 index 000000000000..a45001fdadab --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopaspectjsupport/ApplicationConfiguration.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopaspectjsupport; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; + +// tag::snippet[] +@Configuration +@EnableAspectJAutoProxy +public class ApplicationConfiguration { +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectj/ApplicationConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectj/ApplicationConfiguration.java new file mode 100644 index 000000000000..522acd7ea6d9 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectj/ApplicationConfiguration.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopataspectj; + +import org.springframework.context.annotation.Bean; + +// tag::snippet[] +public class ApplicationConfiguration { + + @Bean + public NotVeryUsefulAspect myAspect() { + NotVeryUsefulAspect myAspect = new NotVeryUsefulAspect(); + // Configure properties of the aspect here + return myAspect; + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectj/NotVeryUsefulAspect.java b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectj/NotVeryUsefulAspect.java new file mode 100644 index 000000000000..337fe1265143 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectj/NotVeryUsefulAspect.java @@ -0,0 +1,24 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopataspectj; + +import org.aspectj.lang.annotation.Aspect; + +// tag::snippet[] +@Aspect +public class NotVeryUsefulAspect { +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ApplicationConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ApplicationConfiguration.java new file mode 100644 index 000000000000..abba61921144 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ApplicationConfiguration.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopataspectjexample; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; + +// tag::snippet[] +@Configuration +@EnableAspectJAutoProxy +public class ApplicationConfiguration { + + @Bean + public ConcurrentOperationExecutor concurrentOperationExecutor() { + ConcurrentOperationExecutor executor = new ConcurrentOperationExecutor(); + executor.setMaxRetries(3); + executor.setOrder(100); + return executor; + + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ConcurrentOperationExecutor.java b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ConcurrentOperationExecutor.java new file mode 100644 index 000000000000..a760f744918f --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ConcurrentOperationExecutor.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopataspectjexample; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; + +import org.springframework.core.Ordered; +import org.springframework.dao.PessimisticLockingFailureException; + +// tag::snippet[] +@Aspect +public class ConcurrentOperationExecutor implements Ordered { + + private static final int DEFAULT_MAX_RETRIES = 2; + + private int maxRetries = DEFAULT_MAX_RETRIES; + private int order = 1; + + public void setMaxRetries(int maxRetries) { + this.maxRetries = maxRetries; + } + + public int getOrder() { + return this.order; + } + + public void setOrder(int order) { + this.order = order; + } + + @Around("com.xyz.CommonPointcuts.businessService()") + public Object doConcurrentOperation(ProceedingJoinPoint pjp) throws Throwable { + int numAttempts = 0; + PessimisticLockingFailureException lockFailureException; + do { + numAttempts++; + try { + return pjp.proceed(); + } + catch(PessimisticLockingFailureException ex) { + lockFailureException = ex; + } + } while(numAttempts <= this.maxRetries); + throw lockFailureException; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/service/Idempotent.java b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/service/Idempotent.java new file mode 100644 index 000000000000..394f21b43a6a --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/service/Idempotent.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopataspectjexample.service; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +// tag::snippet[] +@Retention(RetentionPolicy.RUNTIME) +// marker annotation +public @interface Idempotent { +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/service/SampleService.java b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/service/SampleService.java new file mode 100644 index 000000000000..70636063a604 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/service/SampleService.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopataspectjexample.service; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; + +import org.springframework.stereotype.Service; + +@Service +public class SampleService { + + // tag::snippet[] + @Around("execution(* com.xyz..service.*.*(..)) && " + + "@annotation(com.xyz.service.Idempotent)") + public Object doConcurrentOperation(ProceedingJoinPoint pjp) throws Throwable { + // ... + return pjp.proceed(pjp.getArgs()); + } + // end::snippet[] + +} diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aopapi/aopapipointcutsregex/JdkRegexpConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/aopapi/aopapipointcutsregex/JdkRegexpConfiguration.java new file mode 100644 index 000000000000..84eb3a7f5a8f --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aopapi/aopapipointcutsregex/JdkRegexpConfiguration.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aopapi.aopapipointcutsregex; + +import org.springframework.aop.support.JdkRegexpMethodPointcut; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +// tag::snippet[] +@Configuration +public class JdkRegexpConfiguration { + + @Bean + public JdkRegexpMethodPointcut settersAndAbsquatulatePointcut() { + JdkRegexpMethodPointcut pointcut = new JdkRegexpMethodPointcut(); + pointcut.setPatterns(".*set.*", ".*absquatulate"); + return pointcut; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aopapi/aopapipointcutsregex/RegexpConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/aopapi/aopapipointcutsregex/RegexpConfiguration.java new file mode 100644 index 000000000000..a56be401eba5 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aopapi/aopapipointcutsregex/RegexpConfiguration.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aopapi.aopapipointcutsregex; + +import org.aopalliance.aop.Advice; + +import org.springframework.aop.support.RegexpMethodPointcutAdvisor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +// tag::snippet[] +@Configuration +public class RegexpConfiguration { + + @Bean + public RegexpMethodPointcutAdvisor settersAndAbsquatulateAdvisor(Advice beanNameOfAopAllianceInterceptor) { + RegexpMethodPointcutAdvisor advisor = new RegexpMethodPointcutAdvisor(); + advisor.setAdvice(beanNameOfAopAllianceInterceptor); + advisor.setPatterns(".*set.*", ".*absquatulate"); + return advisor; + } +} +// end::snippet[] + diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aot/hints/reflective/MyConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/aot/hints/reflective/MyConfiguration.java new file mode 100644 index 000000000000..109101f41b79 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aot/hints/reflective/MyConfiguration.java @@ -0,0 +1,25 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aot.hints.reflective; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ReflectiveScan; + +@Configuration +@ReflectiveScan("com.example.app") +public class MyConfiguration { +} diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aot/hints/registerreflection/MyConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/aot/hints/registerreflection/MyConfiguration.java new file mode 100644 index 000000000000..89e0267f6e7e --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aot/hints/registerreflection/MyConfiguration.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aot.hints.registerreflection; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.annotation.RegisterReflection; +import org.springframework.context.annotation.Configuration; + +// tag::snippet[] +@Configuration +@RegisterReflection(classes = AccountService.class, memberCategories = + { MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS }) +class MyConfiguration { +} +// end::snippet[] + +class AccountService {} diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aot/hints/registerreflection/OrderService.java b/framework-docs/src/main/java/org/springframework/docs/core/aot/hints/registerreflection/OrderService.java new file mode 100644 index 000000000000..afc66e8b7c80 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/aot/hints/registerreflection/OrderService.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aot.hints.registerreflection; + +import org.springframework.aot.hint.annotation.RegisterReflectionForBinding; +import org.springframework.stereotype.Component; + +// tag::snippet[] +@Component +class OrderService { + + @RegisterReflectionForBinding(Order.class) + public void process(Order order) { + // ... + } + +} +// end::snippet[] + +record Order() {} diff --git a/framework-docs/src/main/java/org/springframework/docs/core/aot/hints/testing/SampleReflection.java b/framework-docs/src/main/java/org/springframework/docs/core/aot/hints/testing/SampleReflection.java index 93abd52e85a2..2772146e71ea 100644 --- a/framework-docs/src/main/java/org/springframework/docs/core/aot/hints/testing/SampleReflection.java +++ b/framework-docs/src/main/java/org/springframework/docs/core/aot/hints/testing/SampleReflection.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +32,7 @@ public void performReflection() { Class springVersion = ClassUtils.forName("org.springframework.core.SpringVersion", null); Method getVersion = ClassUtils.getMethod(springVersion, "getVersion"); String version = (String) getVersion.invoke(null); - logger.info("Spring version:" + version); + logger.info("Spring version: " + version); } catch (Exception exc) { logger.error("reflection failed", exc); diff --git a/framework-docs/src/main/java/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/AnotherBean.java b/framework-docs/src/main/java/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/AnotherBean.java new file mode 100644 index 000000000000..3bb14d4e7220 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/AnotherBean.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.beans.dependencies.beansfactorylazyinit; + +public class AnotherBean { +} diff --git a/framework-docs/src/main/java/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/ApplicationConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/ApplicationConfiguration.java new file mode 100644 index 000000000000..c1f1d3cc0548 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/ApplicationConfiguration.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.beans.dependencies.beansfactorylazyinit; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; + +@Configuration +public class ApplicationConfiguration { + + // tag::snippet[] + @Bean + @Lazy + ExpensiveToCreateBean lazy() { + return new ExpensiveToCreateBean(); + } + + @Bean + AnotherBean notLazy() { + return new AnotherBean(); + } + // end::snippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/ExpensiveToCreateBean.java b/framework-docs/src/main/java/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/ExpensiveToCreateBean.java new file mode 100644 index 000000000000..1d08cf43247a --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/ExpensiveToCreateBean.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.beans.dependencies.beansfactorylazyinit; + +public class ExpensiveToCreateBean { +} diff --git a/framework-docs/src/main/java/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/LazyConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/LazyConfiguration.java new file mode 100644 index 000000000000..7f2685ba14e1 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/LazyConfiguration.java @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.beans.dependencies.beansfactorylazyinit; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; + +// tag::snippet[] +@Configuration +@Lazy +public class LazyConfiguration { + // No bean will be pre-instantiated... +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/CustomerPreferenceDao.java b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/CustomerPreferenceDao.java new file mode 100644 index 000000000000..cadf841666ce --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/CustomerPreferenceDao.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef; + +public class CustomerPreferenceDao { +} diff --git a/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/FieldValueTestBean.java b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/FieldValueTestBean.java new file mode 100644 index 000000000000..6c795eb47517 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/FieldValueTestBean.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef; + +import org.springframework.beans.factory.annotation.Value; + +// tag::snippet[] +public class FieldValueTestBean { + + @Value("#{ systemProperties['user.region'] }") + private String defaultLocale; + + public void setDefaultLocale(String defaultLocale) { + this.defaultLocale = defaultLocale; + } + + public String getDefaultLocale() { + return this.defaultLocale; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/MovieFinder.java b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/MovieFinder.java new file mode 100644 index 000000000000..aee9cba8d7f5 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/MovieFinder.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef; + +public class MovieFinder { +} diff --git a/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/MovieRecommender.java b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/MovieRecommender.java new file mode 100644 index 000000000000..8ac7b7d9f3ac --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/MovieRecommender.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef; + +import org.springframework.beans.factory.annotation.Value; + +// tag::snippet[] +public class MovieRecommender { + + private String defaultLocale; + + private CustomerPreferenceDao customerPreferenceDao; + + public MovieRecommender(CustomerPreferenceDao customerPreferenceDao, + @Value("#{systemProperties['user.country']}") String defaultLocale) { + this.customerPreferenceDao = customerPreferenceDao; + this.defaultLocale = defaultLocale; + } + + // ... +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/NumberGuess.java b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/NumberGuess.java new file mode 100644 index 000000000000..94b519cc56b9 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/NumberGuess.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef; + +import org.springframework.beans.factory.annotation.Value; + +// tag::snippet[] +public class NumberGuess { + + private double randomNumber; + + @Value("#{ T(java.lang.Math).random() * 100.0 }") + public void setRandomNumber(double randomNumber) { + this.randomNumber = randomNumber; + } + + public double getRandomNumber() { + return randomNumber; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/PropertyValueTestBean.java b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/PropertyValueTestBean.java new file mode 100644 index 000000000000..1bcfad4b1d69 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/PropertyValueTestBean.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef; + +import org.springframework.beans.factory.annotation.Value; + +// tag::snippet[] +public class PropertyValueTestBean { + + private String defaultLocale; + + @Value("#{ systemProperties['user.region'] }") + public void setDefaultLocale(String defaultLocale) { + this.defaultLocale = defaultLocale; + } + + public String getDefaultLocale() { + return this.defaultLocale; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/ShapeGuess.java b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/ShapeGuess.java new file mode 100644 index 000000000000..0fb0da1322cd --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/ShapeGuess.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef; + +import org.springframework.beans.factory.annotation.Value; + +// tag::snippet[] +public class ShapeGuess { + + private double initialShapeSeed; + + @Value("#{ numberGuess.randomNumber }") + public void setInitialShapeSeed(double initialShapeSeed) { + this.initialShapeSeed = initialShapeSeed; + } + + public double getInitialShapeSeed() { + return initialShapeSeed; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/SimpleMovieLister.java b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/SimpleMovieLister.java new file mode 100644 index 000000000000..9ce82ee6b5b4 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/expressions/expressionsbeandef/SimpleMovieLister.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +// tag::snippet[] +public class SimpleMovieLister { + + private MovieFinder movieFinder; + private String defaultLocale; + + @Autowired + public void configure(MovieFinder movieFinder, + @Value("#{ systemProperties['user.region'] }") String defaultLocale) { + this.movieFinder = movieFinder; + this.defaultLocale = defaultLocale; + } + + // ... +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/validation/formatconfiguringformattingglobaldatetimeformat/ApplicationConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/validation/formatconfiguringformattingglobaldatetimeformat/ApplicationConfiguration.java new file mode 100644 index 000000000000..7f151848c9c7 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/validation/formatconfiguringformattingglobaldatetimeformat/ApplicationConfiguration.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.validation.formatconfiguringformattingglobaldatetimeformat; + +import java.time.format.DateTimeFormatter; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.datetime.DateFormatter; +import org.springframework.format.datetime.DateFormatterRegistrar; +import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar; +import org.springframework.format.number.NumberFormatAnnotationFormatterFactory; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.format.support.FormattingConversionService; + +// tag::snippet[] +@Configuration +public class ApplicationConfiguration { + + @Bean + public FormattingConversionService conversionService() { + + // Use the DefaultFormattingConversionService but do not register defaults + DefaultFormattingConversionService conversionService = + new DefaultFormattingConversionService(false); + + // Ensure @NumberFormat is still supported + conversionService.addFormatterForFieldAnnotation( + new NumberFormatAnnotationFormatterFactory()); + + // Register JSR-310 date conversion with a specific global format + DateTimeFormatterRegistrar dateTimeRegistrar = new DateTimeFormatterRegistrar(); + dateTimeRegistrar.setDateFormatter(DateTimeFormatter.ofPattern("yyyyMMdd")); + dateTimeRegistrar.registerFormatters(conversionService); + + // Register date conversion with a specific global format + DateFormatterRegistrar dateRegistrar = new DateFormatterRegistrar(); + dateRegistrar.setFormatter(new DateFormatter("yyyyMMdd")); + dateRegistrar.registerFormatters(conversionService); + + return conversionService; + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/core/validation/validationbeanvalidationspringmethod/ApplicationConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/validation/validationbeanvalidationspringmethod/ApplicationConfiguration.java new file mode 100644 index 000000000000..c25e2ab073d3 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/validation/validationbeanvalidationspringmethod/ApplicationConfiguration.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.validation.validationbeanvalidationspringmethod; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.beanvalidation.MethodValidationPostProcessor; + +// tag::snippet[] +@Configuration +public class ApplicationConfiguration { + + @Bean + public static MethodValidationPostProcessor validationPostProcessor() { + return new MethodValidationPostProcessor(); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/core/validation/validationbeanvalidationspringmethodexceptions/ApplicationConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/core/validation/validationbeanvalidationspringmethodexceptions/ApplicationConfiguration.java new file mode 100644 index 000000000000..ad18753eafe3 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/core/validation/validationbeanvalidationspringmethodexceptions/ApplicationConfiguration.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.validation.validationbeanvalidationspringmethodexceptions; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.beanvalidation.MethodValidationPostProcessor; + +// tag::snippet[] +@Configuration +public class ApplicationConfiguration { + + @Bean + public static MethodValidationPostProcessor validationPostProcessor() { + MethodValidationPostProcessor processor = new MethodValidationPostProcessor(); + processor.setAdaptConstraintViolations(true); + return processor; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/BasicDataSourceConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/BasicDataSourceConfiguration.java new file mode 100644 index 000000000000..2fd9109fc793 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/BasicDataSourceConfiguration.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.dataaccess.jdbc.jdbcdatasource; + +import org.apache.commons.dbcp2.BasicDataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class BasicDataSourceConfiguration { + + // tag::snippet[] + @Bean(destroyMethod = "close") + BasicDataSource dataSource() { + BasicDataSource dataSource = new BasicDataSource(); + dataSource.setDriverClassName("org.hsqldb.jdbcDriver"); + dataSource.setUrl("jdbc:hsqldb:hsql://localhost:"); + dataSource.setUsername("sa"); + dataSource.setPassword(""); + return dataSource; + } + // end::snippet[] + +} diff --git a/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/ComboPooledDataSourceConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/ComboPooledDataSourceConfiguration.java new file mode 100644 index 000000000000..45e842fdbf46 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/ComboPooledDataSourceConfiguration.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.dataaccess.jdbc.jdbcdatasource; + +import java.beans.PropertyVetoException; + +import com.mchange.v2.c3p0.ComboPooledDataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class ComboPooledDataSourceConfiguration { + + // tag::snippet[] + @Bean(destroyMethod = "close") + ComboPooledDataSource dataSource() throws PropertyVetoException { + ComboPooledDataSource dataSource = new ComboPooledDataSource(); + dataSource.setDriverClass("org.hsqldb.jdbcDriver"); + dataSource.setJdbcUrl("jdbc:hsqldb:hsql://localhost:"); + dataSource.setUser("sa"); + dataSource.setPassword(""); + return dataSource; + } + // end::snippet[] + +} diff --git a/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/DriverManagerDataSourceConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/DriverManagerDataSourceConfiguration.java new file mode 100644 index 000000000000..0783c9228907 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/DriverManagerDataSourceConfiguration.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.dataaccess.jdbc.jdbcdatasource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +@Configuration +class DriverManagerDataSourceConfiguration { + + // tag::snippet[] + @Bean + DriverManagerDataSource dataSource() { + DriverManagerDataSource dataSource = new DriverManagerDataSource(); + dataSource.setDriverClassName("org.hsqldb.jdbcDriver"); + dataSource.setUrl("jdbc:hsqldb:hsql://localhost:"); + dataSource.setUsername("sa"); + dataSource.setPassword(""); + return dataSource; + } + // end::snippet[] + +} diff --git a/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcembeddeddatabase/JdbcEmbeddedDatabaseConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcembeddeddatabase/JdbcEmbeddedDatabaseConfiguration.java new file mode 100644 index 000000000000..a48a3c6e800b --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcembeddeddatabase/JdbcEmbeddedDatabaseConfiguration.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.dataaccess.jdbc.jdbcembeddeddatabase; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +@Configuration +public class JdbcEmbeddedDatabaseConfiguration { + + // tag::snippet[] + @Bean + DataSource dataSource() { + return new EmbeddedDatabaseBuilder() + .generateUniqueName(true) + .setType(EmbeddedDatabaseType.H2) + .addScripts("schema.sql", "test-data.sql") + .build(); + } + // end::snippet[] + +} diff --git a/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/CorporateEventDao.java b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/CorporateEventDao.java new file mode 100644 index 000000000000..9762d8ae3804 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/CorporateEventDao.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.dataaccess.jdbc.jdbcjdbctemplateidioms; + +public interface CorporateEventDao { +} diff --git a/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/CorporateEventRepository.java b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/CorporateEventRepository.java new file mode 100644 index 000000000000..a0eb1fe7f881 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/CorporateEventRepository.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.dataaccess.jdbc.jdbcjdbctemplateidioms; + +public interface CorporateEventRepository { +} diff --git a/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDao.java b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDao.java new file mode 100644 index 000000000000..36ab9956b738 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDao.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.dataaccess.jdbc.jdbcjdbctemplateidioms; + +import javax.sql.DataSource; + +import org.springframework.jdbc.core.JdbcTemplate; + +// tag::snippet[] +public class JdbcCorporateEventDao implements CorporateEventDao { + + private final JdbcTemplate jdbcTemplate; + + public JdbcCorporateEventDao(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + // JDBC-backed implementations of the methods on the CorporateEventDao follow... +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDaoConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDaoConfiguration.java new file mode 100644 index 000000000000..799b81835584 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDaoConfiguration.java @@ -0,0 +1,30 @@ +package org.springframework.docs.dataaccess.jdbc.jdbcjdbctemplateidioms; + +import javax.sql.DataSource; + +import org.apache.commons.dbcp2.BasicDataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JdbcCorporateEventDaoConfiguration { + + // tag::snippet[] + @Bean + JdbcCorporateEventDao corporateEventDao(DataSource dataSource) { + return new JdbcCorporateEventDao(dataSource); + } + + @Bean(destroyMethod = "close") + BasicDataSource dataSource() { + BasicDataSource dataSource = new BasicDataSource(); + dataSource.setDriverClassName("org.hsqldb.jdbcDriver"); + dataSource.setUrl("jdbc:hsqldb:hsql://localhost:"); + dataSource.setUsername("sa"); + dataSource.setPassword(""); + return dataSource; + } + // end::snippet[] + +} diff --git a/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventRepository.java b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventRepository.java new file mode 100644 index 000000000000..1597edd00af1 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventRepository.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.dataaccess.jdbc.jdbcjdbctemplateidioms; + +import javax.sql.DataSource; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +// tag::snippet[] +@Repository +public class JdbcCorporateEventRepository implements CorporateEventRepository { + + private JdbcTemplate jdbcTemplate; + + // Implicitly autowire the DataSource constructor parameter + public JdbcCorporateEventRepository(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + // JDBC-backed implementations of the methods on the CorporateEventRepository follow... +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventRepositoryConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventRepositoryConfiguration.java new file mode 100644 index 000000000000..6a95d3f67e52 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventRepositoryConfiguration.java @@ -0,0 +1,25 @@ +package org.springframework.docs.dataaccess.jdbc.jdbcjdbctemplateidioms; + +import org.apache.commons.dbcp2.BasicDataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +// tag::snippet[] +@Configuration +@ComponentScan("org.example.jdbc") +public class JdbcCorporateEventRepositoryConfiguration { + + @Bean(destroyMethod = "close") + BasicDataSource dataSource() { + BasicDataSource dataSource = new BasicDataSource(); + dataSource.setDriverClassName("org.hsqldb.jdbcDriver"); + dataSource.setUrl("jdbc:hsqldb:hsql://localhost:"); + dataSource.setUsername("sa"); + dataSource.setPassword(""); + return dataSource; + } + +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/cache/cacheannotationenable/CacheConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/cache/cacheannotationenable/CacheConfiguration.java new file mode 100644 index 000000000000..3c86a477e952 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/cache/cacheannotationenable/CacheConfiguration.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.cache.cacheannotationenable; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +// tag::snippet[] +@Configuration +@EnableCaching +class CacheConfiguration { + + @Bean + CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + cacheManager.setCacheSpecification("..."); + return cacheManager; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CacheConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CacheConfiguration.java new file mode 100644 index 000000000000..d2548ef6d771 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CacheConfiguration.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.cache.cachestoreconfigurationcaffeine; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class CacheConfiguration { + + // tag::snippet[] + @Bean + CacheManager cacheManager() { + return new CaffeineCacheManager(); + } + // end::snippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CustomCacheConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CustomCacheConfiguration.java new file mode 100644 index 000000000000..a5c27321cfb3 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CustomCacheConfiguration.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.cache.cachestoreconfigurationcaffeine; + +import java.util.List; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class CustomCacheConfiguration { + + // tag::snippet[] + @Bean + CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + cacheManager.setCacheNames(List.of("default", "books")); + return cacheManager; + } + // end::snippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationjdk/CacheConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationjdk/CacheConfiguration.java new file mode 100644 index 000000000000..87aec966c0f0 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationjdk/CacheConfiguration.java @@ -0,0 +1,54 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.cache.cachestoreconfigurationjdk; + +import java.util.Set; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCache; +import org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean; +import org.springframework.cache.support.SimpleCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class CacheConfiguration { + + // tag::snippet[] + @Bean + ConcurrentMapCacheFactoryBean defaultCache() { + ConcurrentMapCacheFactoryBean cache = new ConcurrentMapCacheFactoryBean(); + cache.setName("default"); + return cache; + } + + @Bean + ConcurrentMapCacheFactoryBean booksCache() { + ConcurrentMapCacheFactoryBean cache = new ConcurrentMapCacheFactoryBean(); + cache.setName("books"); + return cache; + } + + @Bean + CacheManager cacheManager(ConcurrentMapCache defaultCache, ConcurrentMapCache booksCache) { + + SimpleCacheManager cacheManager = new SimpleCacheManager(); + cacheManager.setCaches(Set.of(defaultCache, booksCache)); + return cacheManager; + } + // end::snippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationjsr107/CacheConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationjsr107/CacheConfiguration.java new file mode 100644 index 000000000000..874614ef7b90 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationjsr107/CacheConfiguration.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.cache.cachestoreconfigurationjsr107; + +import javax.cache.Caching; +import javax.cache.spi.CachingProvider; + +import org.springframework.cache.jcache.JCacheCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class CacheConfiguration { + + // tag::snippet[] + @Bean + javax.cache.CacheManager jCacheManager() { + CachingProvider cachingProvider = Caching.getCachingProvider(); + return cachingProvider.getCacheManager(); + } + + @Bean + org.springframework.cache.CacheManager cacheManager(javax.cache.CacheManager jCacheManager) { + return new JCacheCacheManager(jCacheManager); + } + // end::snippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationnoop/CacheConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationnoop/CacheConfiguration.java new file mode 100644 index 000000000000..afb57bada1d4 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/cache/cachestoreconfigurationnoop/CacheConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.cache.cachestoreconfigurationnoop; + +import java.util.List; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.support.CompositeCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class CacheConfiguration { + + private CacheManager jdkCache() { + return null; + } + + private CacheManager gemfireCache() { + return null; + } + + // tag::snippet[] + @Bean + CacheManager cacheManager(CacheManager jdkCache, CacheManager gemfireCache) { + CompositeCacheManager cacheManager = new CompositeCacheManager(); + cacheManager.setCacheManagers(List.of(jdkCache, gemfireCache)); + cacheManager.setFallbackToNoOpCache(true); + return cacheManager; + } + // end::snippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsannotatedsupport/JmsConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsannotatedsupport/JmsConfiguration.java new file mode 100644 index 000000000000..932988ce632a --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsannotatedsupport/JmsConfiguration.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsannotatedsupport; + + +import jakarta.jms.ConnectionFactory; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jms.annotation.EnableJms; +import org.springframework.jms.config.DefaultJmsListenerContainerFactory; +import org.springframework.jms.support.destination.DestinationResolver; + +// tag::snippet[] +@Configuration +@EnableJms +public class JmsConfiguration { + + @Bean + public DefaultJmsListenerContainerFactory jmsListenerContainerFactory(ConnectionFactory connectionFactory, + DestinationResolver destinationResolver) { + + DefaultJmsListenerContainerFactory factory = new DefaultJmsListenerContainerFactory(); + factory.setConnectionFactory(connectionFactory); + factory.setDestinationResolver(destinationResolver); + factory.setSessionTransacted(true); + factory.setConcurrency("3-10"); + return factory; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/AlternativeJmsConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/AlternativeJmsConfiguration.java new file mode 100644 index 000000000000..dc183df8eb3a --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/AlternativeJmsConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsjcamessageendpointmanager; + +import jakarta.jms.MessageListener; +import jakarta.resource.spi.ResourceAdapter; +import org.apache.activemq.ra.ActiveMQActivationSpec; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jms.listener.endpoint.JmsMessageEndpointManager; + +@Configuration +public class AlternativeJmsConfiguration { + + // tag::snippet[] + @Bean + JmsMessageEndpointManager jmsMessageEndpointManager(ResourceAdapter resourceAdapter, + MessageListener myMessageListener) { + + ActiveMQActivationSpec spec = new ActiveMQActivationSpec(); + spec.setDestination("myQueue"); + spec.setDestinationType("jakarta.jms.Queue"); + + JmsMessageEndpointManager endpointManager = new JmsMessageEndpointManager(); + endpointManager.setResourceAdapter(resourceAdapter); + endpointManager.setActivationSpec(spec); + endpointManager.setMessageListener(myMessageListener); + return endpointManager; + } + // end::snippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/JmsConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/JmsConfiguration.java new file mode 100644 index 000000000000..a95e1c8aafe3 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/JmsConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsjcamessageendpointmanager; + + +import jakarta.jms.MessageListener; +import jakarta.resource.spi.ResourceAdapter; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jms.listener.endpoint.JmsActivationSpecConfig; +import org.springframework.jms.listener.endpoint.JmsMessageEndpointManager; + +@Configuration +public class JmsConfiguration { + + // tag::snippet[] + @Bean + public JmsMessageEndpointManager jmsMessageEndpointManager(ResourceAdapter resourceAdapter, + MessageListener myMessageListener) { + + JmsActivationSpecConfig specConfig = new JmsActivationSpecConfig(); + specConfig.setDestinationName("myQueue"); + + JmsMessageEndpointManager endpointManager = new JmsMessageEndpointManager(); + endpointManager.setResourceAdapter(resourceAdapter); + endpointManager.setActivationSpecConfig(specConfig); + endpointManager.setMessageListener(myMessageListener); + return endpointManager; + } + // end::snippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasync/ExampleListener.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasync/ExampleListener.java new file mode 100644 index 000000000000..d535f8cb0811 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasync/ExampleListener.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasync; + +import jakarta.jms.JMSException; +import jakarta.jms.Message; +import jakarta.jms.MessageListener; +import jakarta.jms.TextMessage; + +// tag::snippet[] +public class ExampleListener implements MessageListener { + + public void onMessage(Message message) { + if (message instanceof TextMessage textMessage) { + try { + System.out.println(textMessage.getText()); + } + catch (JMSException ex) { + throw new RuntimeException(ex); + } + } + else { + throw new IllegalArgumentException("Message must be of type TextMessage"); + } + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasync/JmsConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasync/JmsConfiguration.java new file mode 100644 index 000000000000..6f9fb309ae25 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasync/JmsConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasync; + +import jakarta.jms.ConnectionFactory; +import jakarta.jms.Destination; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jms.listener.DefaultMessageListenerContainer; + +@Configuration +public class JmsConfiguration { + + // tag::snippet[] + @Bean + ExampleListener messageListener() { + return new ExampleListener(); + } + + @Bean + DefaultMessageListenerContainer jmsContainer(ConnectionFactory connectionFactory, Destination destination, + ExampleListener messageListener) { + + DefaultMessageListenerContainer jmsContainer = new DefaultMessageListenerContainer(); + jmsContainer.setConnectionFactory(connectionFactory); + jmsContainer.setDestination(destination); + jmsContainer.setMessageListener(messageListener); + return jmsContainer; + } + // end::snippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultMessageDelegate.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultMessageDelegate.java new file mode 100644 index 000000000000..acad7cbcc248 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultMessageDelegate.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter; + +import java.io.Serializable; +import java.util.Map; + +// tag::snippet[] +public class DefaultMessageDelegate implements MessageDelegate { + + @Override + public void handleMessage(String message) { + // ... + } + + @Override + public void handleMessage(Map message) { + // ... + } + + @Override + public void handleMessage(byte[] message) { + // ... + } + + @Override + public void handleMessage(Serializable message) { + // ... + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultResponsiveTextMessageDelegate.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultResponsiveTextMessageDelegate.java new file mode 100644 index 000000000000..bfa2e6168455 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultResponsiveTextMessageDelegate.java @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter; + +import jakarta.jms.TextMessage; + +// tag::snippet[] +public class DefaultResponsiveTextMessageDelegate implements ResponsiveTextMessageDelegate { + + @Override + public String receive(TextMessage message) { + return "message"; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultTextMessageDelegate.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultTextMessageDelegate.java new file mode 100644 index 000000000000..00d32876ac26 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultTextMessageDelegate.java @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter; + +import org.springframework.web.socket.TextMessage; + +// tag::snippet[] +public class DefaultTextMessageDelegate implements TextMessageDelegate { + + @Override + public void receive(TextMessage message) { + // ... + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/JmsConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/JmsConfiguration.java new file mode 100644 index 000000000000..6bb0bf75cd1e --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/JmsConfiguration.java @@ -0,0 +1,48 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter; + +import jakarta.jms.ConnectionFactory; +import jakarta.jms.Destination; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.docs.integration.jms.jmsreceivingasync.ExampleListener; +import org.springframework.jms.listener.DefaultMessageListenerContainer; +import org.springframework.jms.listener.adapter.MessageListenerAdapter; + +@Configuration +public class JmsConfiguration { + + // tag::snippet[] + @Bean + MessageListenerAdapter messageListener(DefaultMessageDelegate messageDelegate) { + return new MessageListenerAdapter(messageDelegate); + } + + @Bean + DefaultMessageListenerContainer jmsContainer(ConnectionFactory connectionFactory, Destination destination, + ExampleListener messageListener) { + + DefaultMessageListenerContainer jmsContainer = new DefaultMessageListenerContainer(); + jmsContainer.setConnectionFactory(connectionFactory); + jmsContainer.setDestination(destination); + jmsContainer.setMessageListener(messageListener); + return jmsContainer; + } + // end::snippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageDelegate.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageDelegate.java new file mode 100644 index 000000000000..f15b002ff2f1 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageDelegate.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter; + +import java.io.Serializable; +import java.util.Map; + +// tag::snippet[] +public interface MessageDelegate { + + void handleMessage(String message); + + void handleMessage(Map message); + + void handleMessage(byte[] message); + + void handleMessage(Serializable message); +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageListenerConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageListenerConfiguration.java new file mode 100644 index 000000000000..565e2dffe17f --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageListenerConfiguration.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jms.listener.adapter.MessageListenerAdapter; + +@Configuration +public class MessageListenerConfiguration { + + // tag::snippet[] + @Bean + MessageListenerAdapter messageListener(DefaultTextMessageDelegate messageDelegate) { + MessageListenerAdapter messageListener = new MessageListenerAdapter(messageDelegate); + messageListener.setDefaultListenerMethod("receive"); + // We don't want automatic message context extraction + messageListener.setMessageConverter(null); + return messageListener; + } + // end::snippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/ResponsiveTextMessageDelegate.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/ResponsiveTextMessageDelegate.java new file mode 100644 index 000000000000..ae22b4838fce --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/ResponsiveTextMessageDelegate.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter; + +import jakarta.jms.TextMessage; + +// tag::snippet[] +public interface ResponsiveTextMessageDelegate { + + // Notice the return type... + String receive(TextMessage message); +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/TextMessageDelegate.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/TextMessageDelegate.java new file mode 100644 index 000000000000..ecd0f3007dc8 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/TextMessageDelegate.java @@ -0,0 +1,26 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter; + +import org.springframework.web.socket.TextMessage; + +// tag::snippet[] +public interface TextMessageDelegate { + + void receive(TextMessage message); +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmstxparticipation/ExternalTxJmsConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmstxparticipation/ExternalTxJmsConfiguration.java new file mode 100644 index 000000000000..cd10a82ac930 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmstxparticipation/ExternalTxJmsConfiguration.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmstxparticipation; + +import jakarta.jms.ConnectionFactory; +import jakarta.jms.Destination; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.docs.integration.jms.jmsreceivingasync.ExampleListener; +import org.springframework.jms.listener.DefaultMessageListenerContainer; +import org.springframework.transaction.jta.JtaTransactionManager; + +@Configuration +public class ExternalTxJmsConfiguration { + + // tag::transactionManagerSnippet[] + @Bean + JtaTransactionManager transactionManager() { + return new JtaTransactionManager(); + } + // end::transactionManagerSnippet[] + + // tag::jmsContainerSnippet[] + @Bean + DefaultMessageListenerContainer jmsContainer(ConnectionFactory connectionFactory, Destination destination, + ExampleListener messageListener) { + + DefaultMessageListenerContainer jmsContainer = new DefaultMessageListenerContainer(); + jmsContainer.setConnectionFactory(connectionFactory); + jmsContainer.setDestination(destination); + jmsContainer.setMessageListener(messageListener); + jmsContainer.setSessionTransacted(true); + return jmsContainer; + } + // end::jmsContainerSnippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmstxparticipation/JmsConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmstxparticipation/JmsConfiguration.java new file mode 100644 index 000000000000..bdc8c847e2e3 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmstxparticipation/JmsConfiguration.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmstxparticipation; + +import jakarta.jms.ConnectionFactory; +import jakarta.jms.Destination; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.docs.integration.jms.jmsreceivingasync.ExampleListener; +import org.springframework.jms.listener.DefaultMessageListenerContainer; + +@Configuration +public class JmsConfiguration { + + // tag::snippet[] + @Bean + DefaultMessageListenerContainer jmsContainer(ConnectionFactory connectionFactory, Destination destination, + ExampleListener messageListener) { + + DefaultMessageListenerContainer jmsContainer = new DefaultMessageListenerContainer(); + jmsContainer.setConnectionFactory(connectionFactory); + jmsContainer.setDestination(destination); + jmsContainer.setMessageListener(messageListener); + jmsContainer.setSessionTransacted(true); + return jmsContainer; + } + // end::snippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/CustomJmxConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/CustomJmxConfiguration.java new file mode 100644 index 000000000000..d227fba9dbb3 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/CustomJmxConfiguration.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jmx.jmxcontextmbeanexport; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableMBeanExport; + +// tag::snippet[] +@Configuration +@EnableMBeanExport(server="myMBeanServer", defaultDomain="myDomain") +public class CustomJmxConfiguration { +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/JmxConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/JmxConfiguration.java new file mode 100644 index 000000000000..70f3a08667c2 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/JmxConfiguration.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jmx.jmxcontextmbeanexport; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableMBeanExport; + +// tag::snippet[] +@Configuration +@EnableMBeanExport +public class JmxConfiguration { +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxexporting/IJmxTestBean.java b/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxexporting/IJmxTestBean.java new file mode 100644 index 000000000000..4100da2d922f --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxexporting/IJmxTestBean.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jmx.jmxexporting; + +public interface IJmxTestBean { + + int getAge(); + + void setAge(int age); + + void setName(String name); + + String getName(); + + int add(int x, int y); + + void dontExposeMe(); +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxexporting/JmxConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxexporting/JmxConfiguration.java new file mode 100644 index 000000000000..98ce1302a694 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxexporting/JmxConfiguration.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jmx.jmxexporting; + +import java.util.Map; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jmx.export.MBeanExporter; + +// tag::snippet[] +@Configuration +public class JmxConfiguration { + + @Bean + MBeanExporter exporter(JmxTestBean testBean) { + MBeanExporter exporter = new MBeanExporter(); + exporter.setBeans(Map.of("bean:name=testBean1", testBean)); + return exporter; + } + + @Bean + JmxTestBean testBean() { + JmxTestBean testBean = new JmxTestBean(); + testBean.setName("TEST"); + testBean.setAge(100); + return testBean; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxexporting/JmxTestBean.java b/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxexporting/JmxTestBean.java new file mode 100644 index 000000000000..6412ecabf99a --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jmx/jmxexporting/JmxTestBean.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jmx.jmxexporting; + +// tag::snippet[] +public class JmxTestBean implements IJmxTestBean { + + private String name; + private int age; + + @Override + public int getAge() { + return age; + } + + @Override + public void setAge(int age) { + this.age = age; + } + + @Override + public void setName(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + + @Override + public int add(int x, int y) { + return x + y; + } + + @Override + public void dontExposeMe() { + throw new RuntimeException(); + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/mailusage/OrderManager.java b/framework-docs/src/main/java/org/springframework/docs/integration/mailusage/OrderManager.java new file mode 100644 index 000000000000..b1dab51d7925 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/mailusage/OrderManager.java @@ -0,0 +1,26 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.mailusage; + +import org.springframework.docs.integration.mailusagesimple.Order; + +// tag::snippet[] +public interface OrderManager { + + void placeOrder(Order order); +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/mailusagesimple/Customer.java b/framework-docs/src/main/java/org/springframework/docs/integration/mailusagesimple/Customer.java new file mode 100644 index 000000000000..0d01074c74b6 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/mailusagesimple/Customer.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.mailusagesimple; + +public class Customer { + + public String getEmailAddress() { + return null; + } + + public String getFirstName() { + return null; + } + + public String getLastName() { + return null; + } +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/mailusagesimple/MailConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/mailusagesimple/MailConfiguration.java new file mode 100644 index 000000000000..723340868d4e --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/mailusagesimple/MailConfiguration.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.mailusagesimple; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +@Configuration +public class MailConfiguration { + + // tag::snippet[] + @Bean + JavaMailSender mailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost("mail.mycompany.example"); + return mailSender; + } + + @Bean // this is a template message that we can pre-load with default state + SimpleMailMessage templateMessage() { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom("customerservice@mycompany.example"); + message.setSubject("Your order"); + return message; + } + + @Bean + SimpleOrderManager orderManager(JavaMailSender mailSender, SimpleMailMessage templateMessage) { + SimpleOrderManager orderManager = new SimpleOrderManager(); + orderManager.setMailSender(mailSender); + orderManager.setTemplateMessage(templateMessage); + return orderManager; + } + // end::snippet[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/mailusagesimple/Order.java b/framework-docs/src/main/java/org/springframework/docs/integration/mailusagesimple/Order.java new file mode 100644 index 000000000000..27c53e0d6a53 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/mailusagesimple/Order.java @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.mailusagesimple; + +public class Order { + + public Customer getCustomer() { + return null; + } + + public String getOrderNumber() { + return null; + } +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/mailusagesimple/SimpleOrderManager.java b/framework-docs/src/main/java/org/springframework/docs/integration/mailusagesimple/SimpleOrderManager.java new file mode 100644 index 000000000000..6835d2f0afe0 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/mailusagesimple/SimpleOrderManager.java @@ -0,0 +1,64 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.mailusagesimple; + +import org.springframework.docs.integration.mailusage.OrderManager; +import org.springframework.mail.SimpleMailMessage; + +import org.springframework.mail.MailException; +import org.springframework.mail.MailSender; + +// tag::snippet[] +public class SimpleOrderManager implements OrderManager { + + private MailSender mailSender; + private SimpleMailMessage templateMessage; + + public void setMailSender(MailSender mailSender) { + this.mailSender = mailSender; + } + + public void setTemplateMessage(SimpleMailMessage templateMessage) { + this.templateMessage = templateMessage; + } + + @Override + public void placeOrder(Order order) { + + // Do the business calculations... + + // Call the collaborators to persist the order... + + // Create a thread-safe "copy" of the template message and customize it + SimpleMailMessage msg = new SimpleMailMessage(this.templateMessage); + msg.setTo(order.getCustomer().getEmailAddress()); + msg.setText( + "Dear " + order.getCustomer().getFirstName() + + order.getCustomer().getLastName() + + ", thank you for placing order. Your order number is " + + order.getOrderNumber()); + try { + this.mailSender.send(msg); + } + catch (MailException ex) { + // simply log it and go on... + System.err.println(ex.getMessage()); + } + } + +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/resthttpinterface/customresolver/CustomHttpServiceArgumentResolver.java b/framework-docs/src/main/java/org/springframework/docs/integration/resthttpinterface/customresolver/CustomHttpServiceArgumentResolver.java new file mode 100644 index 000000000000..779654bbad08 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/resthttpinterface/customresolver/CustomHttpServiceArgumentResolver.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.resthttpinterface.customresolver; + +import java.util.List; + +import org.springframework.core.MethodParameter; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.service.invoker.HttpServiceArgumentResolver; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +public class CustomHttpServiceArgumentResolver { + + // tag::httpinterface[] + interface RepositoryService { + + @GetExchange("/repos/search") + List searchRepository(Search search); + + } + // end::httpinterface[] + + class Sample { + + void sample() { + // tag::usage[] + RestClient restClient = RestClient.builder().baseUrl("https://api.github.com/").build(); + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory + .builderFor(adapter) + .customArgumentResolver(new SearchQueryArgumentResolver()) + .build(); + RepositoryService repositoryService = factory.createClient(RepositoryService.class); + + Search search = Search.create() + .owner("spring-projects") + .language("java") + .query("rest") + .build(); + List repositories = repositoryService.searchRepository(search); + // end::usage[] + } + + } + + // tag::argumentresolver[] + static class SearchQueryArgumentResolver implements HttpServiceArgumentResolver { + @Override + public boolean resolve(Object argument, MethodParameter parameter, HttpRequestValues.Builder requestValues) { + if (parameter.getParameterType().equals(Search.class)) { + Search search = (Search) argument; + requestValues.addRequestParameter("owner", search.owner()); + requestValues.addRequestParameter("language", search.language()); + requestValues.addRequestParameter("query", search.query()); + return true; + } + return false; + } + } + // end::argumentresolver[] + + + record Search (String query, String owner, String language) { + + static Builder create() { + return new Builder(); + } + + static class Builder { + + Builder query(String query) { return this;} + + Builder owner(String owner) { return this;} + + Builder language(String language) { return this;} + + Search build() { + return new Search(null, null, null); + } + } + + } + + record Repository(String name) { + + } + +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/schedulingenableannotationsupport/SchedulingConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/schedulingenableannotationsupport/SchedulingConfiguration.java new file mode 100644 index 000000000000..d38b7b4b4c9d --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/schedulingenableannotationsupport/SchedulingConfiguration.java @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.schedulingenableannotationsupport; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +// tag::snippet[] +@Configuration +@EnableAsync +@EnableScheduling +public class SchedulingConfiguration { +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/schedulingtaskexecutorusage/LoggingTaskDecorator.java b/framework-docs/src/main/java/org/springframework/docs/integration/schedulingtaskexecutorusage/LoggingTaskDecorator.java new file mode 100644 index 000000000000..2f92fff0e4c5 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/schedulingtaskexecutorusage/LoggingTaskDecorator.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.schedulingtaskexecutorusage; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.task.TaskDecorator; + +public class LoggingTaskDecorator implements TaskDecorator { + + private static final Log logger = LogFactory.getLog(LoggingTaskDecorator.class); + + @Override + public Runnable decorate(Runnable runnable) { + return () -> { + logger.debug("Before execution of " + runnable); + runnable.run(); + logger.debug("After execution of " + runnable); + }; + } +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorConfiguration.java new file mode 100644 index 000000000000..d5dd6ceab451 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorConfiguration.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.schedulingtaskexecutorusage; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class TaskExecutorConfiguration { + + // tag::snippet[] + @Bean + ThreadPoolTaskExecutor taskExecutor() { + ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor(); + taskExecutor.setCorePoolSize(5); + taskExecutor.setMaxPoolSize(10); + taskExecutor.setQueueCapacity(25); + return taskExecutor; + } + + @Bean + TaskExecutorExample taskExecutorExample(ThreadPoolTaskExecutor taskExecutor) { + return new TaskExecutorExample(taskExecutor); + } + // end::snippet[] + + // tag::decorator[] + @Bean + ThreadPoolTaskExecutor decoratedTaskExecutor() { + ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor(); + taskExecutor.setTaskDecorator(new LoggingTaskDecorator()); + return taskExecutor; + } + // end::decorator[] +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorExample.java b/framework-docs/src/main/java/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorExample.java new file mode 100644 index 000000000000..62a156cff177 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorExample.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.schedulingtaskexecutorusage; + +import org.springframework.core.task.TaskExecutor; + +// tag::snippet[] +public class TaskExecutorExample { + + private class MessagePrinterTask implements Runnable { + + private String message; + + public MessagePrinterTask(String message) { + this.message = message; + } + + public void run() { + System.out.println(message); + } + } + + private TaskExecutor taskExecutor; + + public TaskExecutorExample(TaskExecutor taskExecutor) { + this.taskExecutor = taskExecutor; + } + + public void printMessages() { + for(int i = 0; i < 25; i++) { + taskExecutor.execute(new MessagePrinterTask("Message" + i)); + } + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterassertions/HotelControllerTests.java b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterassertions/HotelControllerTests.java new file mode 100644 index 000000000000..647cb2099db9 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterassertions/HotelControllerTests.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterassertions; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.assertj.MockMvcTester; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HotelControllerTests { + + private final MockMvcTester mockMvc = MockMvcTester.of(new HotelController()); + + + void getHotel() { + // tag::get[] + assertThat(mockMvc.get().uri("/hotels/{id}", 42)) + .hasStatusOk() + .hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .bodyJson().isLenientlyEqualTo("sample/hotel-42.json"); + // end::get[] + } + + + void getHotelInvalid() { + // tag::failure[] + assertThat(mockMvc.get().uri("/hotels/{id}", -1)) + .hasFailed() + .hasStatus(HttpStatus.BAD_REQUEST) + .failure().hasMessageContaining("Identifier should be positive"); + // end::failure[] + } + + static class HotelController {} +} diff --git a/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterassertionsjson/FamilyControllerTests.java b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterassertionsjson/FamilyControllerTests.java new file mode 100644 index 000000000000..96d41a2ea8e4 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterassertionsjson/FamilyControllerTests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterassertionsjson; + +import org.assertj.core.api.InstanceOfAssertFactories; + +import org.springframework.test.web.servlet.assertj.MockMvcTester; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +class FamilyControllerTests { + + private final MockMvcTester mockMvc = MockMvcTester.of(new FamilyController()); + + + void extractingPathAsMap() { + // tag::extract-asmap[] + assertThat(mockMvc.get().uri("/family")).bodyJson() + .extractingPath("$.members[0]") + .asMap() + .contains(entry("name", "Homer")); + // end::extract-asmap[] + } + + void extractingPathAndConvertWithType() { + // tag::extract-convert[] + assertThat(mockMvc.get().uri("/family")).bodyJson() + .extractingPath("$.members[0]") + .convertTo(Member.class) + .satisfies(member -> assertThat(member.name).isEqualTo("Homer")); + // end::extract-convert[] + } + + void extractingPathAndConvertWithAssertFactory() { + // tag::extract-convert-assert-factory[] + assertThat(mockMvc.get().uri("/family")).bodyJson() + .extractingPath("$.members") + .convertTo(InstanceOfAssertFactories.list(Member.class)) + .hasSize(5) + .element(0).satisfies(member -> assertThat(member.name).isEqualTo("Homer")); + // end::extract-convert-assert-factory[] + } + + void assertTheSimpsons() { + // tag::assert-file[] + assertThat(mockMvc.get().uri("/family")).bodyJson() + .isStrictlyEqualTo("sample/simpsons.json"); + // end::assert-file[] + } + + static class FamilyController {} + + record Member(String name) {} +} diff --git a/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterintegration/HotelControllerTests.java b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterintegration/HotelControllerTests.java new file mode 100644 index 000000000000..cc1e6b1c4457 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterintegration/HotelControllerTests.java @@ -0,0 +1,47 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterintegration; + +import org.springframework.test.web.servlet.assertj.MockMvcTester; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class HotelControllerTests { + + private final MockMvcTester mockMvc = MockMvcTester.of(new HotelController()); + + + void perform() { + // tag::perform[] + // Static import on MockMvcRequestBuilders.get + assertThat(mockMvc.perform(get("/hotels/{id}", 42))) + .hasStatusOk(); + // end::perform[] + } + + void performWithCustomMatcher() { + // tag::matches[] + // Static import on MockMvcResultMatchers.status + assertThat(mockMvc.get().uri("/hotels/{id}", 42)) + .matches(status().isOk()); + // end::matches[] + } + + static class HotelController {} +} diff --git a/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequests/HotelControllerTests.java b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequests/HotelControllerTests.java new file mode 100644 index 000000000000..b72da0454c8e --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequests/HotelControllerTests.java @@ -0,0 +1,68 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterrequests; + +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.springframework.test.web.servlet.assertj.MvcTestResult; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * @author Stephane Nicoll + */ +public class HotelControllerTests { + + private final MockMvcTester mockMvc = MockMvcTester.of(new HotelController()); + + + void createHotel() { + // tag::post[] + assertThat(mockMvc.post().uri("/hotels/{id}", 42).accept(MediaType.APPLICATION_JSON)) + . // ... + // end::post[] + hasStatusOk(); + } + + void createHotelMultipleAssertions() { + // tag::post-exchange[] + MvcTestResult result = mockMvc.post().uri("/hotels/{id}", 42) + .accept(MediaType.APPLICATION_JSON).exchange(); + assertThat(result). // ... + // end::post-exchange[] + hasStatusOk(); + } + + void queryParameters() { + // tag::query-parameters[] + assertThat(mockMvc.get().uri("/hotels?thing={thing}", "somewhere")) + . // ... + // end::query-parameters[] + hasStatusOk(); + } + + void parameters() { + // tag::parameters[] + assertThat(mockMvc.get().uri("/hotels").param("thing", "somewhere")) + . // ... + // end::parameters[] + hasStatusOk(); + } + + static class HotelController {} +} diff --git a/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestsasync/AsyncControllerTests.java b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestsasync/AsyncControllerTests.java new file mode 100644 index 000000000000..fa13d3abdff9 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestsasync/AsyncControllerTests.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterrequestsasync; + +import java.time.Duration; + +import org.springframework.test.web.servlet.assertj.MockMvcTester; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AsyncControllerTests { + + private final MockMvcTester mockMvc = MockMvcTester.of(new AsyncController()); + + void asyncExchangeWithCustomTimeToWait() { + // tag::duration[] + assertThat(mockMvc.get().uri("/compute").exchange(Duration.ofSeconds(5))) + . // ... + // end::duration[] + hasStatusOk(); + } + + static class AsyncController {} +} diff --git a/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestsmultipart/MultipartControllerTests.java b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestsmultipart/MultipartControllerTests.java new file mode 100644 index 000000000000..82371dd15cce --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestsmultipart/MultipartControllerTests.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterrequestsmultipart; + +import java.nio.charset.StandardCharsets; + +import org.springframework.test.web.servlet.assertj.MockMvcTester; + +import static org.assertj.core.api.Assertions.assertThat; + +public class MultipartControllerTests { + + private final MockMvcTester mockMvc = MockMvcTester.of(new MultipartController()); + + void multiPart() { + // tag::snippet[] + assertThat(mockMvc.post().uri("/upload").multipart() + .file("file1.txt", "Hello".getBytes(StandardCharsets.UTF_8)) + .file("file2.txt", "World".getBytes(StandardCharsets.UTF_8))) + . // ... + // end::snippet[] + hasStatusOk(); + } + + static class MultipartController {} +} diff --git a/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestspaths/HotelControllerTests.java b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestspaths/HotelControllerTests.java new file mode 100644 index 000000000000..da39047300e2 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestspaths/HotelControllerTests.java @@ -0,0 +1,54 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterrequestspaths; + +import java.util.List; + +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.assertj.MockMvcTester; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * @author Stephane Nicoll + */ +public class HotelControllerTests { + + private final MockMvcTester mockMvc = MockMvcTester.of(new HotelController()); + + void contextAndServletPaths() { + // tag::context-servlet-paths[] + assertThat(mockMvc.get().uri("/app/main/hotels/{id}", 42) + .contextPath("/app").servletPath("/main")) + . // ... + // end::context-servlet-paths[] + hasStatusOk(); + } + + void configureMockMvcTesterWithDefaultSettings() { + // tag::default-customizations[] + MockMvcTester mockMvc = MockMvcTester.of(List.of(new HotelController()), + builder -> builder.defaultRequest(get("/") + .contextPath("/app").servletPath("/main") + .accept(MediaType.APPLICATION_JSON)).build()); + // end::default-customizations[] + } + + + static class HotelController {} +} diff --git a/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountController.java b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountController.java new file mode 100644 index 000000000000..4a71ac3e4364 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountController.java @@ -0,0 +1,25 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctestersetup; + +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class AccountController { + + +} diff --git a/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountControllerIntegrationTests.java b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountControllerIntegrationTests.java new file mode 100644 index 000000000000..0ec02321f2f0 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountControllerIntegrationTests.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctestersetup; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.springframework.web.context.WebApplicationContext; + +// tag::snippet[] +@SpringJUnitWebConfig(ApplicationWebConfiguration.class) +class AccountControllerIntegrationTests { + + private final MockMvcTester mockMvc; + + AccountControllerIntegrationTests(@Autowired WebApplicationContext wac) { + this.mockMvc = MockMvcTester.from(wac); + } + + // ... + +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountControllerStandaloneTests.java b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountControllerStandaloneTests.java new file mode 100644 index 000000000000..d14766d88696 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountControllerStandaloneTests.java @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctestersetup; + +import org.springframework.test.web.servlet.assertj.MockMvcTester; + +// tag::snippet[] +public class AccountControllerStandaloneTests { + + private final MockMvcTester mockMvc = MockMvcTester.of(new AccountController()); + + // ... + +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/ApplicationWebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/ApplicationWebConfiguration.java new file mode 100644 index 000000000000..7d9b7edebf08 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/ApplicationWebConfiguration.java @@ -0,0 +1,25 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctestersetup; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +@Configuration +@EnableWebMvc +public class ApplicationWebConfiguration { +} diff --git a/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/converter/AccountControllerIntegrationTests.java b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/converter/AccountControllerIntegrationTests.java new file mode 100644 index 000000000000..63326e903aa4 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/converter/AccountControllerIntegrationTests.java @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctestersetup.converter; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.docs.testing.mockmvc.assertj.mockmvctestersetup.ApplicationWebConfiguration; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.springframework.web.context.WebApplicationContext; + +// tag::snippet[] +@SpringJUnitWebConfig(ApplicationWebConfiguration.class) +class AccountControllerIntegrationTests { + + private final MockMvcTester mockMvc; + + AccountControllerIntegrationTests(@Autowired WebApplicationContext wac) { + this.mockMvc = MockMvcTester.from(wac).withHttpMessageConverters( + List.of(wac.getBean(AbstractJackson2HttpMessageConverter.class))); + } + + // ... + +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webflux/controller/webfluxanncontrollerexceptions/SimpleController.java b/framework-docs/src/main/java/org/springframework/docs/web/webflux/controller/webfluxanncontrollerexceptions/SimpleController.java new file mode 100644 index 000000000000..423f47a567a6 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webflux/controller/webfluxanncontrollerexceptions/SimpleController.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webflux.controller.webfluxanncontrollerexceptions; + +import java.io.IOException; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Controller +public class SimpleController { + + @ExceptionHandler(IOException.class) + public ResponseEntity handle() { + return ResponseEntity.internalServerError().body("Could not read file storage"); + } + +} \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webflux/controller/webfluxannexceptionhandlermedia/MediaTypeController.java b/framework-docs/src/main/java/org/springframework/docs/web/webflux/controller/webfluxannexceptionhandlermedia/MediaTypeController.java new file mode 100644 index 000000000000..08e700360441 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webflux/controller/webfluxannexceptionhandlermedia/MediaTypeController.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webflux.controller.webfluxannexceptionhandlermedia; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Controller +public class MediaTypeController { + + // tag::mediatype[] + @ExceptionHandler(produces = "application/json") + public ResponseEntity handleJson(IllegalArgumentException exc) { + return ResponseEntity.badRequest().body(new ErrorMessage(exc.getMessage(), 42)); + } + + @ExceptionHandler(produces = "text/html") + public String handle(IllegalArgumentException exc, Model model) { + model.addAttribute("error", new ErrorMessage(exc.getMessage(), 42)); + return "errorView"; + } + // end::mediatype[] + + static record ErrorMessage(String message, int code) { + + } +} diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webflux/filters/urlhandler/UrlHandlerFilterConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webflux/filters/urlhandler/UrlHandlerFilterConfiguration.java new file mode 100644 index 000000000000..d8fbd6864980 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webflux/filters/urlhandler/UrlHandlerFilterConfiguration.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webflux.filters.urlhandler; + +import org.springframework.http.HttpStatus; +import org.springframework.web.filter.reactive.UrlHandlerFilter; + +public class UrlHandlerFilterConfiguration { + + public void configureUrlHandlerFilter() { + // tag::config[] + UrlHandlerFilter urlHandlerFilter = UrlHandlerFilter + // will HTTP 308 redirect "/blog/my-blog-post/" -> "/blog/my-blog-post" + .trailingSlashHandler("/blog/**").redirect(HttpStatus.PERMANENT_REDIRECT) + // will mutate the request to "/admin/user/account/" and make it as "/admin/user/account" + .trailingSlashHandler("/admin/**").mutateRequest() + .build(); + // end::config[] + } +} diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webflux/webfluxconfigpathmatching/WebConfig.java b/framework-docs/src/main/java/org/springframework/docs/web/webflux/webfluxconfigpathmatching/WebConfig.java index 9048f6333435..ddd53b3683eb 100644 --- a/framework-docs/src/main/java/org/springframework/docs/web/webflux/webfluxconfigpathmatching/WebConfig.java +++ b/framework-docs/src/main/java/org/springframework/docs/web/webflux/webfluxconfigpathmatching/WebConfig.java @@ -19,12 +19,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.method.HandlerTypePredicate; -import org.springframework.web.reactive.config.EnableWebFlux; import org.springframework.web.reactive.config.PathMatchConfigurer; import org.springframework.web.reactive.config.WebFluxConfigurer; @Configuration -@EnableWebFlux public class WebConfig implements WebFluxConfigurer { @Override diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/filters/urlhandler/UrlHandlerFilterConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/filters/urlhandler/UrlHandlerFilterConfiguration.java new file mode 100644 index 000000000000..ba1a4408d9c4 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/filters/urlhandler/UrlHandlerFilterConfiguration.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.filters.urlhandler; + +import org.springframework.http.HttpStatus; +import org.springframework.web.filter.UrlHandlerFilter; + +public class UrlHandlerFilterConfiguration { + + public void configureUrlHandlerFilter() { + // tag::config[] + UrlHandlerFilter urlHandlerFilter = UrlHandlerFilter + // will HTTP 308 redirect "/blog/my-blog-post/" -> "/blog/my-blog-post" + .trailingSlashHandler("/blog/**").redirect(HttpStatus.PERMANENT_REDIRECT) + // will wrap the request to "/admin/user/account/" and make it as "/admin/user/account" + .trailingSlashHandler("/admin/**").wrapRequest() + .build(); + // end::config[] + } +} diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigadvancedjava/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigadvancedjava/WebConfiguration.java new file mode 100644 index 000000000000..f6f386efc17b --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigadvancedjava/WebConfiguration.java @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigadvancedjava; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.DelegatingWebMvcConfiguration; + +// tag::snippet[] +@Configuration +public class WebConfiguration extends DelegatingWebMvcConfiguration { + + // ... +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigadvancedxml/MyPostProcessor.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigadvancedxml/MyPostProcessor.java new file mode 100644 index 000000000000..f3649e9accdb --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigadvancedxml/MyPostProcessor.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigadvancedxml; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.stereotype.Component; + +// tag::snippet[] +@Component +public class MyPostProcessor implements BeanPostProcessor { + + public Object postProcessBeforeInitialization(Object bean, String name) throws BeansException { + // ... + return bean; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcontentnegotiation/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcontentnegotiation/WebConfiguration.java new file mode 100644 index 000000000000..9697847decbc --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcontentnegotiation/WebConfiguration.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigcontentnegotiation; + +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +// tag::snippet[] +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + + @Override + public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { + configurer.mediaType("json", MediaType.APPLICATION_JSON); + configurer.mediaType("xml", MediaType.APPLICATION_XML); + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/DateTimeWebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/DateTimeWebConfiguration.java new file mode 100644 index 000000000000..efa8b5090ad9 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/DateTimeWebConfiguration.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigconversion; + +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +// tag::snippet[] +@Configuration +public class DateTimeWebConfiguration implements WebMvcConfigurer { + + @Override + public void addFormatters(FormatterRegistry registry) { + DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar(); + registrar.setUseIsoFormat(true); + registrar.registerFormatters(registry); + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/WebConfiguration.java new file mode 100644 index 000000000000..ec7166f54f60 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/WebConfiguration.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigconversion; + +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +// tag::snippet[] +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + + @Override + public void addFormatters(FormatterRegistry registry) { + // ... + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcustomize/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcustomize/WebConfiguration.java new file mode 100644 index 000000000000..f6aee7304608 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcustomize/WebConfiguration.java @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigcustomize; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +// tag::snippet[] +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + + // Implement configuration methods... +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigenable/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigenable/WebConfiguration.java new file mode 100644 index 000000000000..8616dd994fbb --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigenable/WebConfiguration.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigenable; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +// tag::snippet[] +@Configuration +@EnableWebMvc +public class WebConfiguration { +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfiginterceptors/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfiginterceptors/WebConfiguration.java new file mode 100644 index 000000000000..e23168d9b191 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfiginterceptors/WebConfiguration.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfiginterceptors; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; + +// tag::snippet[] +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new LocaleChangeInterceptor()); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigmessageconverters/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigmessageconverters/WebConfiguration.java new file mode 100644 index 000000000000..019a99a270cb --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigmessageconverters/WebConfiguration.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigmessageconverters; + +import java.text.SimpleDateFormat; +import java.util.List; + +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; + +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +// tag::snippet[] +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + + @Override + public void configureMessageConverters(List> converters) { + Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder() + .indentOutput(true) + .dateFormat(new SimpleDateFormat("yyyy-MM-dd")) + .modulesToInstall(new ParameterNamesModule()); + converters.add(new MappingJackson2HttpMessageConverter(builder.build())); + converters.add(new MappingJackson2XmlHttpMessageConverter(builder.createXmlMapper(true).build())); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigpathmatching/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigpathmatching/WebConfiguration.java new file mode 100644 index 000000000000..f08527787ddb --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigpathmatching/WebConfiguration.java @@ -0,0 +1,25 @@ +package org.springframework.docs.web.webmvc.mvcconfig.mvcconfigpathmatching; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerTypePredicate; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.util.pattern.PathPatternParser; + +// tag::snippet[] +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + configurer.addPathPrefix("/api", HandlerTypePredicate.forAnnotation(RestController.class)); + } + + private PathPatternParser patternParser() { + PathPatternParser pathPatternParser = new PathPatternParser(); + // ... + return pathPatternParser; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/VersionedConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/VersionedConfiguration.java new file mode 100644 index 000000000000..10c07a4eab7c --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/VersionedConfiguration.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigstaticresources; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.resource.VersionResourceResolver; + +// tag::snippet[] +@Configuration +public class VersionedConfiguration implements WebMvcConfigurer { + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/resources/**") + .addResourceLocations("/public/") + .resourceChain(true) + .addResolver(new VersionResourceResolver().addContentVersionStrategy("/**")); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/WebConfiguration.java new file mode 100644 index 000000000000..46df090b4d13 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/WebConfiguration.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigstaticresources; + +import java.time.Duration; + +import org.springframework.context.annotation.Configuration; +import org.springframework.http.CacheControl; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +// tag::snippet[] +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/resources/**") + .addResourceLocations("/public", "classpath:/static/") + .setCacheControl(CacheControl.maxAge(Duration.ofDays(365))); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/FooValidator.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/FooValidator.java new file mode 100644 index 000000000000..5fc1c723632f --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/FooValidator.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigvalidation; + +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +public class FooValidator implements Validator { + + @Override + public boolean supports(Class clazz) { + return false; + } + + @Override + public void validate(Object target, Errors errors) { + } +} diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/MyController.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/MyController.java new file mode 100644 index 000000000000..0214a63ffcec --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/MyController.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigvalidation; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.annotation.InitBinder; + +// tag::snippet[] +@Controller +public class MyController { + + @InitBinder + public void initBinder(WebDataBinder binder) { + binder.addValidators(new FooValidator()); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/WebConfiguration.java new file mode 100644 index 000000000000..5e8f46abc61e --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/WebConfiguration.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigvalidation; + +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.Validator; +import org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +// tag::snippet[] +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + + @Override + public Validator getValidator() { + Validator validator = new OptionalValidatorFactoryBean(); + // ... + return validator; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewcontroller/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewcontroller/WebConfiguration.java new file mode 100644 index 000000000000..f623fda64a6e --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewcontroller/WebConfiguration.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigviewcontroller; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +// tag::snippet[] +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + + @Override + public void addViewControllers(ViewControllerRegistry registry) { + registry.addViewController("/").setViewName("home"); + } +} +// end::snippet[] + diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/FreeMarkerConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/FreeMarkerConfiguration.java new file mode 100644 index 000000000000..0d949748a323 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/FreeMarkerConfiguration.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigviewresolvers; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ViewResolverRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer; +import org.springframework.web.servlet.view.json.MappingJackson2JsonView; + +// tag::snippet[] +@Configuration +public class FreeMarkerConfiguration implements WebMvcConfigurer { + + @Override + public void configureViewResolvers(ViewResolverRegistry registry) { + registry.enableContentNegotiation(new MappingJackson2JsonView()); + registry.freeMarker().cache(false); + } + + @Bean + public FreeMarkerConfigurer freeMarkerConfigurer() { + FreeMarkerConfigurer configurer = new FreeMarkerConfigurer(); + configurer.setTemplateLoaderPath("/freemarker"); + return configurer; + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/WebConfiguration.java new file mode 100644 index 000000000000..c4d27f555ebd --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/WebConfiguration.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigviewresolvers; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ViewResolverRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.view.json.MappingJackson2JsonView; + +// tag::snippet[] +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + + @Override + public void configureViewResolvers(ViewResolverRegistry registry) { + registry.enableContentNegotiation(new MappingJackson2JsonView()); + registry.jsp(); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/CustomDefaultServletConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/CustomDefaultServletConfiguration.java new file mode 100644 index 000000000000..055263cac89c --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/CustomDefaultServletConfiguration.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcdefaultservlethandler; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +// tag::snippet[] +@Configuration +public class CustomDefaultServletConfiguration implements WebMvcConfigurer { + + @Override + public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) { + configurer.enable("myCustomDefaultServlet"); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/WebConfiguration.java new file mode 100644 index 000000000000..49a110e68096 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/WebConfiguration.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcdefaultservlethandler; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +// tag::snippet[] +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + + @Override + public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) { + configurer.enable(); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcanncontroller/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcanncontroller/WebConfiguration.java new file mode 100644 index 000000000000..84aab1258cf4 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcanncontroller/WebConfiguration.java @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvccontroller.mvcanncontroller; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +// tag::snippet[] +@Configuration +@ComponentScan("org.example.web") +public class WebConfiguration { + + // ... +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandler/SimpleController.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandler/SimpleController.java new file mode 100644 index 000000000000..8da9c174e126 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandler/SimpleController.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvccontroller.mvcannexceptionhandler; + +import java.io.IOException; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Controller +public class SimpleController { + + @ExceptionHandler(IOException.class) + public ResponseEntity handle() { + return ResponseEntity.internalServerError().body("Could not read file storage"); + } + +} \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlerexc/ExceptionController.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlerexc/ExceptionController.java new file mode 100644 index 000000000000..c11617638db1 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlerexc/ExceptionController.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvccontroller.mvcannexceptionhandlerexc; + +import java.io.IOException; +import java.nio.file.FileSystemException; +import java.rmi.RemoteException; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Controller +public class ExceptionController { + + // tag::narrow[] + @ExceptionHandler({FileSystemException.class, RemoteException.class}) + public ResponseEntity handleIoException(IOException ex) { + return ResponseEntity.internalServerError().body(ex.getMessage()); + } + // end::narrow[] + + + // tag::general[] + @ExceptionHandler({FileSystemException.class, RemoteException.class}) + public ResponseEntity handleExceptions(Exception ex) { + return ResponseEntity.internalServerError().body(ex.getMessage()); + } + // end::general[] + +} diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlermedia/MediaTypeController.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlermedia/MediaTypeController.java new file mode 100644 index 000000000000..feecf5f67735 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlermedia/MediaTypeController.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvccontroller.mvcannexceptionhandlermedia; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Controller +public class MediaTypeController { + + // tag::mediatype[] + @ExceptionHandler(produces = "application/json") + public ResponseEntity handleJson(IllegalArgumentException exc) { + return ResponseEntity.badRequest().body(new ErrorMessage(exc.getMessage(), 42)); + } + + @ExceptionHandler(produces = "text/html") + public String handle(IllegalArgumentException exc, Model model) { + model.addAttribute("error", new ErrorMessage(exc.getMessage(), 42)); + return "errorView"; + } + // end::mediatype[] + + static record ErrorMessage(String message, int code) { + + } +} diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompauthenticationtokenbased/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompauthenticationtokenbased/WebSocketConfiguration.java new file mode 100644 index 000000000000..2fb295375b2e --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompauthenticationtokenbased/WebSocketConfiguration.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompauthenticationtokenbased; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(new ChannelInterceptor() { + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + // Access authentication header(s) and invoke accessor.setUser(user) + } + return message; + } + }); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/MessageSizeLimitWebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/MessageSizeLimitWebSocketConfiguration.java new file mode 100644 index 000000000000..303e245f24f8 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/MessageSizeLimitWebSocketConfiguration.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompconfigurationperformance; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class MessageSizeLimitWebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registration) { + registration.setMessageSizeLimit(128 * 1024); + } + + // ... + +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/WebSocketConfiguration.java new file mode 100644 index 000000000000..82d556dd59de --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/WebSocketConfiguration.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompconfigurationperformance; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registration) { + registration.setSendTimeLimit(15 * 1000).setSendBufferSizeLimit(512 * 1024); + } + + // ... + +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/RedController.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/RedController.java new file mode 100644 index 000000000000..70f6ce808900 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/RedController.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompdestinationseparator; + +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.stereotype.Controller; + +// tag::snippet[] +@Controller +@MessageMapping("red") +public class RedController { + + @MessageMapping("blue.{green}") + public void handleGreen(@DestinationVariable String green) { + // ... + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/WebSocketConfiguration.java new file mode 100644 index 000000000000..269f61bb800c --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/WebSocketConfiguration.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompdestinationseparator; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + // ... + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setPathMatcher(new AntPathMatcher(".")); + registry.enableStompBrokerRelay("/queue", "/topic"); + registry.setApplicationDestinationPrefixes("/app"); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompenable/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompenable/WebSocketConfiguration.java new file mode 100644 index 000000000000..0b282736aaaa --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompenable/WebSocketConfiguration.java @@ -0,0 +1,47 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompenable; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // /portfolio is the HTTP URL for the endpoint to which a WebSocket (or SockJS) + // client needs to connect for the WebSocket handshake + registry.addEndpoint("/portfolio"); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + // STOMP messages whose destination header begins with /app are routed to + // @MessageMapping methods in @Controller classes + config.setApplicationDestinationPrefixes("/app"); + // Use the built-in message broker for subscriptions and broadcasting and + // route messages whose destination header begins with /topic or /queue to the broker + config.enableSimpleBroker("/topic", "/queue"); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelay/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelay/WebSocketConfiguration.java new file mode 100644 index 000000000000..20ddf32694f9 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelay/WebSocketConfiguration.java @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstomphandlebrokerrelay; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/portfolio").withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableStompBrokerRelay("/topic", "/queue"); + registry.setApplicationDestinationPrefixes("/app"); + } + +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelayconfigure/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelayconfigure/WebSocketConfiguration.java new file mode 100644 index 000000000000..eb1b255157a7 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelayconfigure/WebSocketConfiguration.java @@ -0,0 +1,47 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstomphandlebrokerrelayconfigure; + +import java.net.InetSocketAddress; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.simp.stomp.StompReactorNettyCodec; +import org.springframework.messaging.tcp.reactor.ReactorNettyTcpClient; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + // ... + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableStompBrokerRelay("/queue/", "/topic/").setTcpClient(createTcpClient()); + registry.setApplicationDestinationPrefixes("/app"); + } + + private ReactorNettyTcpClient createTcpClient() { + return new ReactorNettyTcpClient<>( + client -> client.remoteAddress(() -> new InetSocketAddress(0)), + new StompReactorNettyCodec()); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomphandlesimplebroker/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomphandlesimplebroker/WebSocketConfiguration.java new file mode 100644 index 000000000000..b6518257ce66 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomphandlesimplebroker/WebSocketConfiguration.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstomphandlesimplebroker; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + private TaskScheduler messageBrokerTaskScheduler; + + @Autowired + public void setMessageBrokerTaskScheduler(@Lazy TaskScheduler taskScheduler) { + this.messageBrokerTaskScheduler = taskScheduler; + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/queue/", "/topic/") + .setHeartbeatValue(new long[] {10000, 20000}) + .setTaskScheduler(this.messageBrokerTaskScheduler); + + // ... + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompinterceptors/MyChannelInterceptor.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompinterceptors/MyChannelInterceptor.java new file mode 100644 index 000000000000..721d631301ab --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompinterceptors/MyChannelInterceptor.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompinterceptors; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; + +// tag::snippet[] +public class MyChannelInterceptor implements ChannelInterceptor { + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + StompCommand command = accessor.getCommand(); + // ... + return message; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompinterceptors/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompinterceptors/WebSocketConfiguration.java new file mode 100644 index 000000000000..31082f7991d2 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompinterceptors/WebSocketConfiguration.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompinterceptors; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(new MyChannelInterceptor()); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompmessageflow/GreetingController.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompmessageflow/GreetingController.java new file mode 100644 index 000000000000..9f8302ac11f2 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompmessageflow/GreetingController.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompmessageflow; + +import java.text.SimpleDateFormat; +import java.util.Date; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.stereotype.Controller; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +// tag::snippet[] +@Controller +public class GreetingController { + + @MessageMapping("/greeting") + public String handle(String greeting) { + return "[" + getTimestamp() + ": " + greeting; + } + + private String getTimestamp() { + return new SimpleDateFormat("MM/dd/yyyy h:mm:ss a").format(new Date()); + } + +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompmessageflow/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompmessageflow/WebSocketConfiguration.java new file mode 100644 index 000000000000..4198cc93e94a --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompmessageflow/WebSocketConfiguration.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompmessageflow; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/portfolio"); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setApplicationDestinationPrefixes("/app"); + registry.enableSimpleBroker("/topic"); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/PublishOrderWebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/PublishOrderWebSocketConfiguration.java new file mode 100644 index 000000000000..527d2bb7bab0 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/PublishOrderWebSocketConfiguration.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstomporderedmessages; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class PublishOrderWebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + // ... + registry.setPreservePublishOrder(true); + } + +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/ReceiveOrderWebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/ReceiveOrderWebSocketConfiguration.java new file mode 100644 index 000000000000..9f787007bf64 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/ReceiveOrderWebSocketConfiguration.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstomporderedmessages; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class ReceiveOrderWebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.setPreserveReceiveOrder(true); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompserverconfig/JettyWebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompserverconfig/JettyWebSocketConfiguration.java new file mode 100644 index 000000000000..78e4c99773e9 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompserverconfig/JettyWebSocketConfiguration.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompserverconfig; + +import java.time.Duration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.server.jetty.JettyRequestUpgradeStrategy; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class JettyWebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/portfolio").setHandshakeHandler(handshakeHandler()); + } + + @Bean + public DefaultHandshakeHandler handshakeHandler() { + JettyRequestUpgradeStrategy strategy = new JettyRequestUpgradeStrategy(); + strategy.addWebSocketConfigurer(configurable -> { + configurable.setInputBufferSize(4 * 8192); + configurable.setIdleTimeout(Duration.ofSeconds(600)); + }); + return new DefaultHandshakeHandler(strategy); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompserverconfig/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompserverconfig/WebSocketConfiguration.java new file mode 100644 index 000000000000..b43079e82b04 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/stomp/websocketstompserverconfig/WebSocketConfiguration.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompserverconfig; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registry) { + registry.setMessageSizeLimit(4 * 8192); + registry.setTimeToFirstMessage(30000); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketfallbacksockjsenable/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketfallbacksockjsenable/WebSocketConfiguration.java new file mode 100644 index 000000000000..dde6939608b6 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketfallbacksockjsenable/WebSocketConfiguration.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketfallbacksockjsenable; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.docs.web.websocket.websocketserverhandler.MyHandler; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +// tag::snippet[] +@Configuration +@EnableWebSocket +public class WebSocketConfiguration implements WebSocketConfigurer { + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(myHandler(), "/myHandler").withSockJS(); + } + + @Bean + public WebSocketHandler myHandler() { + return new MyHandler(); + } + +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverallowedorigins/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverallowedorigins/WebSocketConfiguration.java new file mode 100644 index 000000000000..632136a0dacf --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverallowedorigins/WebSocketConfiguration.java @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketserverallowedorigins; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.docs.web.websocket.websocketserverhandler.MyHandler; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +// tag::snippet[] +@Configuration +@EnableWebSocket +public class WebSocketConfiguration implements WebSocketConfigurer { + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(myHandler(), "/myHandler").setAllowedOrigins("https://mydomain.com"); + } + + @Bean + public WebSocketHandler myHandler() { + return new MyHandler(); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverhandler/MyHandler.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverhandler/MyHandler.java new file mode 100644 index 000000000000..ed4be8e7d7f1 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverhandler/MyHandler.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketserverhandler; + +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +// tag::snippet[] +public class MyHandler extends TextWebSocketHandler { + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + // ... + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverhandler/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverhandler/WebSocketConfiguration.java new file mode 100644 index 000000000000..753a598eedaf --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverhandler/WebSocketConfiguration.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketserverhandler; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +// tag::snippet[] +@Configuration +@EnableWebSocket +public class WebSocketConfiguration implements WebSocketConfigurer { + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(myHandler(), "/myHandler"); + } + + @Bean + public WebSocketHandler myHandler() { + return new MyHandler(); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverhandshake/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverhandshake/WebSocketConfiguration.java new file mode 100644 index 000000000000..87b5d64325f5 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverhandshake/WebSocketConfiguration.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketserverhandshake; + +import org.springframework.context.annotation.Configuration; +import org.springframework.docs.web.websocket.websocketserverhandler.MyHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; +import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; + +// tag::snippet[] +@Configuration +@EnableWebSocket +public class WebSocketConfiguration implements WebSocketConfigurer { + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(new MyHandler(), "/myHandler") + .addInterceptors(new HttpSessionHandshakeInterceptor()); + } + +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/JettyWebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/JettyWebSocketConfiguration.java new file mode 100644 index 000000000000..07897a2a2d31 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/JettyWebSocketConfiguration.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketserverruntimeconfiguration; + +import java.time.Duration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; +import org.springframework.web.socket.server.jetty.JettyRequestUpgradeStrategy; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; + +// tag::snippet[] +@Configuration +@EnableWebSocket +public class JettyWebSocketConfiguration implements WebSocketConfigurer { + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(echoWebSocketHandler(), "/echo").setHandshakeHandler(handshakeHandler()); + } + + @Bean + public WebSocketHandler echoWebSocketHandler() { + return new MyEchoHandler(); + } + + @Bean + public DefaultHandshakeHandler handshakeHandler() { + JettyRequestUpgradeStrategy strategy = new JettyRequestUpgradeStrategy(); + strategy.addWebSocketConfigurer(configurable -> { + configurable.setInputBufferSize(8192); + configurable.setIdleTimeout(Duration.ofSeconds(600)); + }); + return new DefaultHandshakeHandler(strategy); + } +} +// end::snippet[] diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/MyEchoHandler.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/MyEchoHandler.java new file mode 100644 index 000000000000..76f78d5e9785 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/MyEchoHandler.java @@ -0,0 +1,22 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketserverruntimeconfiguration; + +import org.springframework.web.socket.handler.AbstractWebSocketHandler; + +public class MyEchoHandler extends AbstractWebSocketHandler { +} diff --git a/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/WebSocketConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/WebSocketConfiguration.java new file mode 100644 index 000000000000..95c74290c5c0 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/WebSocketConfiguration.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketserverruntimeconfiguration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; + +// tag::snippet[] +@Configuration +public class WebSocketConfiguration { + + @Bean + public ServletServerContainerFactoryBean createWebSocketContainer() { + ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); + container.setMaxTextMessageBufferSize(8192); + container.setMaxBinaryMessageBufferSize(8192); + return container; + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/aopajltwspring/ApplicationConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/aopajltwspring/ApplicationConfiguration.kt new file mode 100644 index 000000000000..8c5635a64111 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/aopajltwspring/ApplicationConfiguration.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.aopajltwspring + +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableLoadTimeWeaving + +// tag::snippet[] +@Configuration +@EnableLoadTimeWeaving +class ApplicationConfiguration +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/aopajltwspring/CustomWeaverConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/aopajltwspring/CustomWeaverConfiguration.kt new file mode 100644 index 000000000000..587efae332a1 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/aopajltwspring/CustomWeaverConfiguration.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.aopajltwspring + +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableLoadTimeWeaving +import org.springframework.context.annotation.LoadTimeWeavingConfigurer +import org.springframework.instrument.classloading.LoadTimeWeaver +import org.springframework.instrument.classloading.ReflectiveLoadTimeWeaver + +// tag::snippet[] +@Configuration +@EnableLoadTimeWeaving +class CustomWeaverConfiguration : LoadTimeWeavingConfigurer { + + override fun getLoadTimeWeaver(): LoadTimeWeaver { + return ReflectiveLoadTimeWeaver() + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/aopatconfigurable/ApplicationConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/aopatconfigurable/ApplicationConfiguration.kt new file mode 100644 index 000000000000..ff92f4e372ef --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/aopatconfigurable/ApplicationConfiguration.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.aopatconfigurable + +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.aspectj.EnableSpringConfigured + +// tag::snippet[] +@Configuration +@EnableSpringConfigured +class ApplicationConfiguration +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopaspectjsupport/ApplicationConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopaspectjsupport/ApplicationConfiguration.kt new file mode 100644 index 000000000000..ca3544a39920 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopaspectjsupport/ApplicationConfiguration.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopaspectjsupport + +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy + +// tag::snippet[] +@Configuration +@EnableAspectJAutoProxy +class ApplicationConfiguration +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectj/ApplicationConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectj/ApplicationConfiguration.kt new file mode 100644 index 000000000000..ebeb56710f92 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectj/ApplicationConfiguration.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopataspectj + +import org.springframework.context.annotation.Bean + +// tag::snippet[] +class ApplicationConfiguration { + + @Bean + fun myAspect() = NotVeryUsefulAspect().apply { + // Configure properties of the aspect here + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectj/NotVeryUsefulAspect.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectj/NotVeryUsefulAspect.kt new file mode 100644 index 000000000000..53942988ecbd --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectj/NotVeryUsefulAspect.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopataspectj + +import org.aspectj.lang.annotation.Aspect + +// tag::snippet[] +@Aspect +class NotVeryUsefulAspect +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ApplicationConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ApplicationConfiguration.kt new file mode 100644 index 000000000000..3e961535846c --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ApplicationConfiguration.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopataspectjexample + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy + +// tag::snippet[] +@Configuration +@EnableAspectJAutoProxy +class ApplicationConfiguration { + + @Bean + fun concurrentOperationExecutor() = ConcurrentOperationExecutor().apply { + maxRetries = 3 + order = 100 + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ConcurrentOperationExecutor.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ConcurrentOperationExecutor.kt new file mode 100644 index 000000000000..c4f918f51fec --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ConcurrentOperationExecutor.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopataspectjexample + +import org.aspectj.lang.ProceedingJoinPoint +import org.aspectj.lang.annotation.Around +import org.aspectj.lang.annotation.Aspect +import org.springframework.core.Ordered +import org.springframework.dao.PessimisticLockingFailureException + +// tag::snippet[] +@Aspect +class ConcurrentOperationExecutor : Ordered { + + companion object { + private const val DEFAULT_MAX_RETRIES = 2 + } + + var maxRetries = DEFAULT_MAX_RETRIES + + private var order = 1 + + override fun getOrder(): Int { + return this.order + } + + fun setOrder(order: Int) { + this.order = order + } + + @Around("com.xyz.CommonPointcuts.businessService()") + fun doConcurrentOperation(pjp: ProceedingJoinPoint): Any { + var numAttempts = 0 + var lockFailureException: PessimisticLockingFailureException? + do { + numAttempts++ + try { + return pjp.proceed() + } catch (ex: PessimisticLockingFailureException) { + lockFailureException = ex + } + } while (numAttempts <= this.maxRetries) + throw lockFailureException!! + } +} // end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/service/Idempotent.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/service/Idempotent.kt new file mode 100644 index 000000000000..89224a8d4884 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/service/Idempotent.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopataspectjexample.service + +// tag::snippet[] +@Retention(AnnotationRetention.RUNTIME) +// marker annotation +annotation class Idempotent +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/service/SampleService.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/service/SampleService.kt new file mode 100644 index 000000000000..da5131d74869 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/service/SampleService.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aop.ataspectj.aopataspectjexample.service + +import org.aspectj.lang.ProceedingJoinPoint +import org.aspectj.lang.annotation.Around +import org.springframework.stereotype.Service + +@Service +class SampleService { + + // tag::snippet[] + @Around("execution(* com.xyz..service.*.*(..)) && " + + "@annotation(com.xyz.service.Idempotent)") + fun doConcurrentOperation(pjp: ProceedingJoinPoint): Any? { + // ... + return pjp.proceed(pjp.args) + } + // end::snippet[] + +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/aopapi/aopapipointcutsregex/JdkRegexpConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/aopapi/aopapipointcutsregex/JdkRegexpConfiguration.kt new file mode 100644 index 000000000000..16a6cba610bb --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/aopapi/aopapipointcutsregex/JdkRegexpConfiguration.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aopapi.aopapipointcutsregex + +import org.springframework.aop.support.JdkRegexpMethodPointcut +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +// tag::snippet[] +@Configuration +class JdkRegexpConfiguration { + + @Bean + fun settersAndAbsquatulatePointcut() = JdkRegexpMethodPointcut().apply { + setPatterns(".*set.*", ".*absquatulate") + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/aopapi/aopapipointcutsregex/RegexpConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/aopapi/aopapipointcutsregex/RegexpConfiguration.kt new file mode 100644 index 000000000000..2ae08344f64f --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/aopapi/aopapipointcutsregex/RegexpConfiguration.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.aopapi.aopapipointcutsregex + +import org.aopalliance.aop.Advice +import org.springframework.aop.support.RegexpMethodPointcutAdvisor +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +// tag::snippet[] +@Configuration +class RegexpConfiguration { + + @Bean + fun settersAndAbsquatulateAdvisor(beanNameOfAopAllianceInterceptor: Advice) = RegexpMethodPointcutAdvisor().apply { + advice = beanNameOfAopAllianceInterceptor + setPatterns(".*set.*", ".*absquatulate") + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/ApplicationConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/ApplicationConfiguration.kt new file mode 100644 index 000000000000..cf19c8859a7e --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/ApplicationConfiguration.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.beans.dependencies.beansfactorylazyinit + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Lazy + +@Configuration +class ApplicationConfiguration { + + // tag::snippet[] + @Bean + @Lazy + fun lazy(): ExpensiveToCreateBean { + return ExpensiveToCreateBean() + } + + @Bean + fun notLazy(): AnotherBean { + return AnotherBean() + } + // end::snippet[] +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/LazyConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/LazyConfiguration.kt new file mode 100644 index 000000000000..0cd083e0fdb1 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/LazyConfiguration.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.beans.dependencies.beansfactorylazyinit + +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Lazy + +// tag::snippet[] +@Configuration +@Lazy +class LazyConfiguration { + // No bean will be pre-instantiated... +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/FieldValueTestBean.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/FieldValueTestBean.kt new file mode 100644 index 000000000000..4130074c621f --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/FieldValueTestBean.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef + +import org.springframework.beans.factory.annotation.Value + +// tag::snippet[] +class FieldValueTestBean { + + @field:Value("#{ systemProperties['user.region'] }") + lateinit var defaultLocale: String +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/MovieRecommender.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/MovieRecommender.kt new file mode 100644 index 000000000000..6dd298a7bc7a --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/MovieRecommender.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef + +import org.springframework.beans.factory.annotation.Value + +// tag::snippet[] +class MovieRecommender(private val customerPreferenceDao: CustomerPreferenceDao, + @Value("#{systemProperties['user.country']}") + private val defaultLocale: String) { + // ... +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/NumberGuess.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/NumberGuess.kt new file mode 100644 index 000000000000..e6dc38a729aa --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/NumberGuess.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef + +import org.springframework.beans.factory.annotation.Value + +// tag::snippet[] +class NumberGuess { + + @set:Value("#{ T(java.lang.Math).random() * 100.0 }") + var randomNumber: Double = 0.0 +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/PropertyValueTestBean.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/PropertyValueTestBean.kt new file mode 100644 index 000000000000..ee79955657da --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/PropertyValueTestBean.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef + +import org.springframework.beans.factory.annotation.Value + +// tag::snippet[] +class PropertyValueTestBean { + + @set:Value("#{ systemProperties['user.region'] }") + lateinit var defaultLocale: String +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/ShapeGuess.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/ShapeGuess.kt new file mode 100644 index 000000000000..ee713a26d5af --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/ShapeGuess.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef + +import org.springframework.beans.factory.annotation.Value + +// tag::snippet[] +class ShapeGuess { + + @set:Value("#{ numberGuess.randomNumber }") + var initialShapeSeed: Double = 0.0 +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/SimpleMovieLister.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/SimpleMovieLister.kt new file mode 100644 index 000000000000..f863b880c75f --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/expressions/expressionsbeandef/SimpleMovieLister.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.expressions.expressionsbeandef + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value + +// tag::snippet[] +class SimpleMovieLister { + + private lateinit var movieFinder: MovieFinder + private lateinit var defaultLocale: String + + @Autowired + fun configure(movieFinder: MovieFinder, + @Value("#{ systemProperties['user.region'] }") defaultLocale: String) { + this.movieFinder = movieFinder + this.defaultLocale = defaultLocale + } + + // ... +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/validation/formatconfiguringformattingglobaldatetimeformat/ApplicationConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/validation/formatconfiguringformattingglobaldatetimeformat/ApplicationConfiguration.kt new file mode 100644 index 000000000000..c4688db94871 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/validation/formatconfiguringformattingglobaldatetimeformat/ApplicationConfiguration.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.validation.formatconfiguringformattingglobaldatetimeformat + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.format.datetime.DateFormatter +import org.springframework.format.datetime.DateFormatterRegistrar +import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar +import org.springframework.format.number.NumberFormatAnnotationFormatterFactory +import org.springframework.format.support.DefaultFormattingConversionService +import org.springframework.format.support.FormattingConversionService +import java.time.format.DateTimeFormatter + +// tag::snippet[] +@Configuration +class ApplicationConfiguration { + + @Bean + fun conversionService(): FormattingConversionService { + // Use the DefaultFormattingConversionService but do not register defaults + return DefaultFormattingConversionService(false).apply { + + // Ensure @NumberFormat is still supported + addFormatterForFieldAnnotation(NumberFormatAnnotationFormatterFactory()) + + // Register JSR-310 date conversion with a specific global format + val dateTimeRegistrar = DateTimeFormatterRegistrar() + dateTimeRegistrar.setDateFormatter(DateTimeFormatter.ofPattern("yyyyMMdd")) + dateTimeRegistrar.registerFormatters(this) + + // Register date conversion with a specific global format + val dateRegistrar = DateFormatterRegistrar() + dateRegistrar.setFormatter(DateFormatter("yyyyMMdd")) + dateRegistrar.registerFormatters(this) + } + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/validation/validationbeanvalidationspringmethod/ApplicationConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/validation/validationbeanvalidationspringmethod/ApplicationConfiguration.kt new file mode 100644 index 000000000000..be0946c8376a --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/validation/validationbeanvalidationspringmethod/ApplicationConfiguration.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.validation.validationbeanvalidationspringmethod + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.validation.beanvalidation.MethodValidationPostProcessor + +// tag::snippet[] +@Configuration +class ApplicationConfiguration { + + companion object { + + @Bean + @JvmStatic + fun validationPostProcessor() = MethodValidationPostProcessor() + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/core/validation/validationbeanvalidationspringmethodexceptions/ApplicationConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/core/validation/validationbeanvalidationspringmethodexceptions/ApplicationConfiguration.kt new file mode 100644 index 000000000000..07e383481edb --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/core/validation/validationbeanvalidationspringmethodexceptions/ApplicationConfiguration.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.core.validation.validationbeanvalidationspringmethodexceptions + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.validation.beanvalidation.MethodValidationPostProcessor + +// tag::snippet[] +@Configuration +class ApplicationConfiguration { + + companion object { + + @Bean + @JvmStatic + fun validationPostProcessor() = MethodValidationPostProcessor().apply { + setAdaptConstraintViolations(true) + } + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/BasicDataSourceConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/BasicDataSourceConfiguration.kt new file mode 100644 index 000000000000..1f29d0e8fef7 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/BasicDataSourceConfiguration.kt @@ -0,0 +1,19 @@ +package org.springframework.docs.dataaccess.jdbc.jdbcdatasource + +import org.apache.commons.dbcp2.BasicDataSource +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class BasicDataSourceConfiguration { + + // tag::snippet[] + @Bean(destroyMethod = "close") + fun dataSource() = BasicDataSource().apply { + driverClassName = "org.hsqldb.jdbcDriver" + url = "jdbc:hsqldb:hsql://localhost:" + username = "sa" + password = "" + } + // end::snippet[] +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/ComboPooledDataSourceConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/ComboPooledDataSourceConfiguration.kt new file mode 100644 index 000000000000..0c290c9ebd5f --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/ComboPooledDataSourceConfiguration.kt @@ -0,0 +1,20 @@ +package org.springframework.docs.dataaccess.jdbc.jdbcdatasource + +import com.mchange.v2.c3p0.ComboPooledDataSource +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +internal class ComboPooledDataSourceConfiguration { + + // tag::snippet[] + @Bean(destroyMethod = "close") + fun dataSource() = ComboPooledDataSource().apply { + driverClass = "org.hsqldb.jdbcDriver" + jdbcUrl = "jdbc:hsqldb:hsql://localhost:" + user = "sa" + password = "" + } + // end::snippet[] + +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/DriverManagerDataSourceConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/DriverManagerDataSourceConfiguration.kt new file mode 100644 index 000000000000..87692df00b05 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/DriverManagerDataSourceConfiguration.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.dataaccess.jdbc.jdbcdatasource + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.jdbc.datasource.DriverManagerDataSource + +@Configuration +class DriverManagerDataSourceConfiguration { + + // tag::snippet[] + @Bean + fun dataSource() = DriverManagerDataSource().apply { + setDriverClassName("org.hsqldb.jdbcDriver") + url = "jdbc:hsqldb:hsql://localhost:" + username = "sa" + password = "" + } + // end::snippet[] + +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcembeddeddatabase/JdbcEmbeddedDatabaseConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcembeddeddatabase/JdbcEmbeddedDatabaseConfiguration.kt new file mode 100644 index 000000000000..7eab9f733af1 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcembeddeddatabase/JdbcEmbeddedDatabaseConfiguration.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.dataaccess.jdbc.jdbcembeddeddatabase + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType + +@Configuration +class JdbcEmbeddedDatabaseConfiguration { + + // tag::snippet[] + @Bean + fun dataSource() = EmbeddedDatabaseBuilder() + .generateUniqueName(true) + .setType(EmbeddedDatabaseType.H2) + .addScripts("schema.sql", "test-data.sql") + .build() + // end::snippet[] + +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDao.kt b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDao.kt new file mode 100644 index 000000000000..0591b7c2765a --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDao.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.dataaccess.jdbc.jdbcjdbctemplateidioms + +import org.springframework.jdbc.core.JdbcTemplate +import javax.sql.DataSource + +// tag::snippet[] +class JdbcCorporateEventDao(dataSource: DataSource): CorporateEventDao { + + private val jdbcTemplate = JdbcTemplate(dataSource) + + // JDBC-backed implementations of the methods on the CorporateEventDao follow... +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDaoConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDaoConfiguration.kt new file mode 100644 index 000000000000..ceb82cc7e0c7 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDaoConfiguration.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.dataaccess.jdbc.jdbcjdbctemplateidioms + +import org.apache.commons.dbcp2.BasicDataSource +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import javax.sql.DataSource + +@Configuration +class JdbcCorporateEventDaoConfiguration { + + // tag::snippet[] + @Bean + fun corporateEventDao(dataSource: DataSource) = JdbcCorporateEventDao(dataSource) + + @Bean(destroyMethod = "close") + fun dataSource() = BasicDataSource().apply { + driverClassName = "org.hsqldb.jdbcDriver" + url = "jdbc:hsqldb:hsql://localhost:" + username = "sa" + password = "" + } + // end::snippet[] + +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventRepositoryConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventRepositoryConfiguration.kt new file mode 100644 index 000000000000..0c3e8c11a079 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventRepositoryConfiguration.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.dataaccess.jdbc.jdbcjdbctemplateidioms + +import org.apache.commons.dbcp2.BasicDataSource +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration + +// tag::snippet[] +@Configuration +@ComponentScan("org.example.jdbc") +class JdbcCorporateEventRepositoryConfiguration { + + @Bean(destroyMethod = "close") + fun dataSource() = BasicDataSource().apply { + driverClassName = "org.hsqldb.jdbcDriver" + url = "jdbc:hsqldb:hsql://localhost:" + username = "sa" + password = "" + } + +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cacheannotationenable/CacheConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cacheannotationenable/CacheConfiguration.kt new file mode 100644 index 000000000000..674b8aad85c6 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cacheannotationenable/CacheConfiguration.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.cache.cacheannotationenable + +import org.springframework.cache.CacheManager +import org.springframework.cache.annotation.EnableCaching +import org.springframework.cache.caffeine.CaffeineCacheManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +// tag::snippet[] +@Configuration +@EnableCaching +class CacheConfiguration { + + @Bean + fun cacheManager(): CacheManager { + return CaffeineCacheManager().apply { + setCacheSpecification("...") + } + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CacheConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CacheConfiguration.kt new file mode 100644 index 000000000000..8e85c14bbbe9 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CacheConfiguration.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.cache.cachestoreconfigurationcaffeine + +import org.springframework.cache.CacheManager +import org.springframework.cache.caffeine.CaffeineCacheManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + + +@Configuration +class CacheConfiguration { + + // tag::snippet[] + @Bean + fun cacheManager(): CacheManager { + return CaffeineCacheManager() + } + // end::snippet[] +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CustomCacheConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CustomCacheConfiguration.kt new file mode 100644 index 000000000000..f91e78a54875 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CustomCacheConfiguration.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.cache.cachestoreconfigurationcaffeine + +import org.springframework.cache.CacheManager +import org.springframework.cache.caffeine.CaffeineCacheManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + + +@Configuration +class CustomCacheConfiguration { + + // tag::snippet[] + @Bean + fun cacheManager(): CacheManager { + return CaffeineCacheManager().apply { + cacheNames = listOf("default", "books") + } + } + // end::snippet[] +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationjdk/CacheConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationjdk/CacheConfiguration.kt new file mode 100644 index 000000000000..779b1168eb95 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationjdk/CacheConfiguration.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.cache.cachestoreconfigurationjdk + +import org.springframework.cache.CacheManager +import org.springframework.cache.concurrent.ConcurrentMapCache +import org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean +import org.springframework.cache.support.SimpleCacheManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + + +@Configuration +class CacheConfiguration { + + // tag::snippet[] + @Bean + fun defaultCache(): ConcurrentMapCacheFactoryBean { + return ConcurrentMapCacheFactoryBean().apply { + setName("default") + } + } + + @Bean + fun booksCache(): ConcurrentMapCacheFactoryBean { + return ConcurrentMapCacheFactoryBean().apply { + setName("books") + } + } + + @Bean + fun cacheManager(defaultCache: ConcurrentMapCache, booksCache: ConcurrentMapCache): CacheManager { + return SimpleCacheManager().apply { + setCaches(setOf(defaultCache, booksCache)) + } + } + // end::snippet[] +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationjsr107/CacheConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationjsr107/CacheConfiguration.kt new file mode 100644 index 000000000000..308f60cd7cfd --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationjsr107/CacheConfiguration.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.cache.cachestoreconfigurationjsr107 + +import org.springframework.cache.jcache.JCacheCacheManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import javax.cache.Caching + +@Suppress("UsePropertyAccessSyntax") +@Configuration +class CacheConfiguration { + + // tag::snippet[] + @Bean + fun jCacheManager(): javax.cache.CacheManager { + val cachingProvider = Caching.getCachingProvider() + return cachingProvider.getCacheManager() + } + + @Bean + fun cacheManager(jCacheManager: javax.cache.CacheManager): org.springframework.cache.CacheManager { + return JCacheCacheManager(jCacheManager) + } + // end::snippet[] +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationnoop/CacheConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationnoop/CacheConfiguration.kt new file mode 100644 index 000000000000..5fc16ea91edc --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/cache/cachestoreconfigurationnoop/CacheConfiguration.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.cache.cachestoreconfigurationnoop + +import org.springframework.cache.CacheManager +import org.springframework.cache.support.CompositeCacheManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class CacheConfiguration { + + private fun jdkCache(): CacheManager? { + return null + } + + private fun gemfireCache(): CacheManager? { + return null + } + + // tag::snippet[] + @Bean + fun cacheManager(jdkCache: CacheManager, gemfireCache: CacheManager): CacheManager { + return CompositeCacheManager().apply { + setCacheManagers(listOf(jdkCache, gemfireCache)) + setFallbackToNoOpCache(true) + } + } + // end::snippet[] +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsannotatedsupport/JmsConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsannotatedsupport/JmsConfiguration.kt new file mode 100644 index 000000000000..2972d22898d6 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsannotatedsupport/JmsConfiguration.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsannotatedsupport + +import jakarta.jms.ConnectionFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.jms.annotation.EnableJms +import org.springframework.jms.config.DefaultJmsListenerContainerFactory +import org.springframework.jms.support.destination.DestinationResolver + +// tag::snippet[] +@Configuration +@EnableJms +class JmsConfiguration { + + @Bean + fun jmsListenerContainerFactory(connectionFactory: ConnectionFactory, destinationResolver: DestinationResolver) = + DefaultJmsListenerContainerFactory().apply { + setConnectionFactory(connectionFactory) + setDestinationResolver(destinationResolver) + setSessionTransacted(true) + setConcurrency("3-10") + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/AlternativeJmsConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/AlternativeJmsConfiguration.kt new file mode 100644 index 000000000000..9127de9c7a47 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/AlternativeJmsConfiguration.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsjcamessageendpointmanager + +import jakarta.jms.MessageListener +import jakarta.resource.spi.ResourceAdapter +import org.apache.activemq.ra.ActiveMQActivationSpec +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.jms.listener.endpoint.JmsMessageEndpointManager + +@Configuration +class AlternativeJmsConfiguration { + + // tag::snippet[] + @Bean + fun jmsMessageEndpointManager( + resourceAdapter: ResourceAdapter, myMessageListener: MessageListener) = JmsMessageEndpointManager().apply { + setResourceAdapter(resourceAdapter) + activationSpec = ActiveMQActivationSpec().apply { + destination = "myQueue" + destinationType = "jakarta.jms.Queue" + } + messageListener = myMessageListener + } + // end::snippet[] +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/JmsConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/JmsConfiguration.kt new file mode 100644 index 000000000000..d7fecece981b --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/JmsConfiguration.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsjcamessageendpointmanager + +import jakarta.jms.MessageListener +import jakarta.resource.spi.ResourceAdapter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.jms.listener.endpoint.JmsActivationSpecConfig +import org.springframework.jms.listener.endpoint.JmsMessageEndpointManager + +@Configuration +class JmsConfiguration { + + // tag::snippet[] + @Bean + fun jmsMessageEndpointManager( + resourceAdapter: ResourceAdapter, myMessageListener: MessageListener) = JmsMessageEndpointManager().apply { + setResourceAdapter(resourceAdapter) + activationSpecConfig = JmsActivationSpecConfig().apply { + destinationName = "myQueue" + } + messageListener = myMessageListener + } + // end::snippet[] +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasync/ExampleListener.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasync/ExampleListener.kt new file mode 100644 index 000000000000..002813ae7b4c --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasync/ExampleListener.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasync + +import jakarta.jms.JMSException +import jakarta.jms.Message +import jakarta.jms.MessageListener +import jakarta.jms.TextMessage + +// tag::snippet[] +class ExampleListener : MessageListener { + + override fun onMessage(message: Message) { + if (message is TextMessage) { + try { + println(message.text) + } catch (ex: JMSException) { + throw RuntimeException(ex) + } + } else { + throw IllegalArgumentException("Message must be of type TextMessage") + } + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasync/JmsConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasync/JmsConfiguration.kt new file mode 100644 index 000000000000..2b0975e2778c --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasync/JmsConfiguration.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasync + +import jakarta.jms.ConnectionFactory +import jakarta.jms.Destination +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.jms.listener.DefaultMessageListenerContainer + +@Configuration +class JmsConfiguration { + + // tag::snippet[] + @Bean + fun messageListener() = ExampleListener() + + @Bean + fun jmsContainer(connectionFactory: ConnectionFactory, destination: Destination, messageListener: ExampleListener) = + DefaultMessageListenerContainer().apply { + setConnectionFactory(connectionFactory) + setDestination(destination) + setMessageListener(messageListener) + } + // end::snippet[] +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultMessageDelegate.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultMessageDelegate.kt new file mode 100644 index 000000000000..c28c9a1ec6c0 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultMessageDelegate.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter + +import java.io.Serializable + +// tag::snippet[] +class DefaultMessageDelegate : MessageDelegate { + + override fun handleMessage(message: String) { + // ... + } + + override fun handleMessage(message: Map<*, *>) { + // ... + } + + override fun handleMessage(message: ByteArray) { + // ... + } + + override fun handleMessage(message: Serializable) { + // ... + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultResponsiveTextMessageDelegate.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultResponsiveTextMessageDelegate.kt new file mode 100644 index 000000000000..fd4c58b6da22 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultResponsiveTextMessageDelegate.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter + +import jakarta.jms.TextMessage + +// tag::snippet[] +class DefaultResponsiveTextMessageDelegate : ResponsiveTextMessageDelegate { + + override fun receive(message: TextMessage): String { + return "message" + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultTextMessageDelegate.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultTextMessageDelegate.kt new file mode 100644 index 000000000000..3a4ab53c5e2c --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/DefaultTextMessageDelegate.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter + +import org.springframework.web.socket.TextMessage + +// tag::snippet[] +class DefaultTextMessageDelegate : TextMessageDelegate { + + override fun receive(message: TextMessage) { + // ... + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/JmsConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/JmsConfiguration.kt new file mode 100644 index 000000000000..14ff4c8c515b --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/JmsConfiguration.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter + +import jakarta.jms.ConnectionFactory +import jakarta.jms.Destination +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.docs.integration.jms.jmsreceivingasync.ExampleListener +import org.springframework.jms.listener.DefaultMessageListenerContainer +import org.springframework.jms.listener.adapter.MessageListenerAdapter + +@Configuration +class JmsConfiguration { + + // tag::snippet[] + @Bean + fun messageListener(messageDelegate: DefaultMessageDelegate): MessageListenerAdapter { + return MessageListenerAdapter(messageDelegate) + } + + @Bean + fun jmsContainer(connectionFactory: ConnectionFactory, destination: Destination, messageListener: ExampleListener) = + DefaultMessageListenerContainer().apply { + setConnectionFactory(connectionFactory) + setDestination(destination) + setMessageListener(messageListener) + } + // end::snippet[] +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageDelegate.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageDelegate.kt new file mode 100644 index 000000000000..fe55a8e740d0 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageDelegate.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter + +import java.io.Serializable + +// tag::snippet[] +interface MessageDelegate { + fun handleMessage(message: String) + fun handleMessage(message: Map<*, *>) + fun handleMessage(message: ByteArray) + fun handleMessage(message: Serializable) +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageListenerConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageListenerConfiguration.kt new file mode 100644 index 000000000000..b77d0dc85612 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageListenerConfiguration.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.jms.listener.adapter.MessageListenerAdapter + +@Configuration +class MessageListenerConfiguration { + + // tag::snippet[] + @Bean + fun messageListener(messageDelegate: DefaultTextMessageDelegate) = MessageListenerAdapter(messageDelegate).apply { + setDefaultListenerMethod("receive") + // We don't want automatic message context extraction + setMessageConverter(null) + } + // end::snippet[] +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/ResponsiveTextMessageDelegate.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/ResponsiveTextMessageDelegate.kt new file mode 100644 index 000000000000..7f6ba5eacc3f --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/ResponsiveTextMessageDelegate.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter + +import jakarta.jms.TextMessage + +// tag::snippet[] +interface ResponsiveTextMessageDelegate { + + // Notice the return type... + fun receive(message: TextMessage): String +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/TextMessageDelegate.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/TextMessageDelegate.kt new file mode 100644 index 000000000000..d90a04b9b3ef --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/TextMessageDelegate.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmsreceivingasyncmessagelisteneradapter + +import org.springframework.web.socket.TextMessage + +// tag::snippet[] +interface TextMessageDelegate { + fun receive(message: TextMessage) +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmstxparticipation/ExternalTxJmsConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmstxparticipation/ExternalTxJmsConfiguration.kt new file mode 100644 index 000000000000..1805e2c373b9 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmstxparticipation/ExternalTxJmsConfiguration.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmstxparticipation + +import jakarta.jms.ConnectionFactory +import jakarta.jms.Destination +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.docs.integration.jms.jmsreceivingasync.ExampleListener +import org.springframework.jms.listener.DefaultMessageListenerContainer +import org.springframework.transaction.jta.JtaTransactionManager + +@Configuration +class ExternalTxJmsConfiguration { + + // tag::transactionManagerSnippet[] + @Bean + fun transactionManager() = JtaTransactionManager() + // end::transactionManagerSnippet[] + + // tag::jmsContainerSnippet[] + @Bean + fun jmsContainer(connectionFactory: ConnectionFactory, destination: Destination, messageListener: ExampleListener, + transactionManager: JtaTransactionManager) = + DefaultMessageListenerContainer().apply { + setConnectionFactory(connectionFactory) + setDestination(destination) + setMessageListener(messageListener) + setTransactionManager(transactionManager) + } + // end::jmsContainerSnippet[] +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmstxparticipation/JmsConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmstxparticipation/JmsConfiguration.kt new file mode 100644 index 000000000000..ce0a5b085cbf --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jms/jmstxparticipation/JmsConfiguration.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jms.jmstxparticipation + +import jakarta.jms.ConnectionFactory +import jakarta.jms.Destination +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.docs.integration.jms.jmsreceivingasync.ExampleListener +import org.springframework.jms.listener.DefaultMessageListenerContainer + +@Configuration +class JmsConfiguration { + + // tag::snippet[] + @Bean + fun jmsContainer(connectionFactory: ConnectionFactory, destination: Destination, messageListener: ExampleListener) = + DefaultMessageListenerContainer().apply { + setConnectionFactory(connectionFactory) + setDestination(destination) + setMessageListener(messageListener) + isSessionTransacted = true + } + // end::snippet[] +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/CustomJmxConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/CustomJmxConfiguration.kt new file mode 100644 index 000000000000..98d9e5f04551 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/CustomJmxConfiguration.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jmx.jmxcontextmbeanexport + +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableMBeanExport + +// tag::snippet[] +@Configuration +@EnableMBeanExport(server="myMBeanServer", defaultDomain="myDomain") +class CustomJmxConfiguration +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/JmxConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/JmxConfiguration.kt new file mode 100644 index 000000000000..9f88e70ced10 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/JmxConfiguration.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jmx.jmxcontextmbeanexport + +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableMBeanExport + +// tag::snippet[] +@Configuration +@EnableMBeanExport +class JmxConfiguration +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jmx/jmxexporting/JmxConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jmx/jmxexporting/JmxConfiguration.kt new file mode 100644 index 000000000000..1f3210110e45 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jmx/jmxexporting/JmxConfiguration.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jmx.jmxexporting + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.jmx.export.MBeanExporter + +// tag::snippet[] +@Configuration +class JmxConfiguration { + + @Bean + fun exporter(testBean: JmxTestBean) = MBeanExporter().apply { + setBeans(mapOf("bean:name=testBean1" to testBean)) + } + + @Bean + fun testBean() = JmxTestBean().apply { + name = "TEST" + age = 100 + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/jmx/jmxexporting/JmxTestBean.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jmx/jmxexporting/JmxTestBean.kt new file mode 100644 index 000000000000..a4936ed4d947 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/jmx/jmxexporting/JmxTestBean.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.jmx.jmxexporting + +// tag::snippet[] +class JmxTestBean : IJmxTestBean { + + private lateinit var name: String + private var age = 0 + + override fun getAge(): Int { + return age + } + + override fun setAge(age: Int) { + this.age = age + } + + override fun setName(name: String) { + this.name = name + } + + override fun getName(): String { + return name + } + + override fun add(x: Int, y: Int): Int { + return x + y + } + + override fun dontExposeMe() { + throw RuntimeException() + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/mailusage/OrderManager.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/mailusage/OrderManager.kt new file mode 100644 index 000000000000..08f7b80d12d4 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/mailusage/OrderManager.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.mailusage + +import org.springframework.docs.integration.mailusagesimple.Order + +// tag::snippet[] +interface OrderManager { + + fun placeOrder(order: Order) +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/mailusagesimple/MailConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/mailusagesimple/MailConfiguration.kt new file mode 100644 index 000000000000..a4f0378da92c --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/mailusagesimple/MailConfiguration.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.mailusagesimple + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.mail.SimpleMailMessage +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.mail.javamail.JavaMailSenderImpl + +@Configuration +class MailConfiguration { + + // tag::snippet[] + @Bean + fun mailSender(): JavaMailSender { + return JavaMailSenderImpl().apply { + host = "mail.mycompany.example" + } + } + + @Bean // this is a template message that we can pre-load with default state + fun templateMessage() = SimpleMailMessage().apply { + from = "customerservice@mycompany.example" + subject = "Your order" + } + + + @Bean + fun orderManager(javaMailSender: JavaMailSender, simpleTemplateMessage: SimpleMailMessage) = SimpleOrderManager().apply { + mailSender = javaMailSender + templateMessage = simpleTemplateMessage + } + // end::snippet[] +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/mailusagesimple/SimpleOrderManager.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/mailusagesimple/SimpleOrderManager.kt new file mode 100644 index 000000000000..7be7aa437df3 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/mailusagesimple/SimpleOrderManager.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.mailusagesimple + +import org.springframework.docs.integration.mailusage.OrderManager +import org.springframework.mail.MailException +import org.springframework.mail.MailSender +import org.springframework.mail.SimpleMailMessage + +// tag::snippet[] +class SimpleOrderManager : OrderManager { + + lateinit var mailSender: MailSender + lateinit var templateMessage: SimpleMailMessage + + override fun placeOrder(order: Order) { + // Do the business calculations... + + // Call the collaborators to persist the order... + + // Create a thread-safe "copy" of the template message and customize it + + val msg = SimpleMailMessage(this.templateMessage) + msg.setTo(order.customer.emailAddress) + msg.text = ("Dear " + order.customer.firstName + + order.customer.lastName + + ", thank you for placing order. Your order number is " + + order.orderNumber) + try { + mailSender.send(msg) + } catch (ex: MailException) { + // simply log it and go on... + System.err.println(ex.message) + } + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/resthttpinterface/customresolver/CustomHttpServiceArgumentResolver.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/resthttpinterface/customresolver/CustomHttpServiceArgumentResolver.kt new file mode 100644 index 000000000000..41b42517b9c1 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/resthttpinterface/customresolver/CustomHttpServiceArgumentResolver.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.resthttpinterface.customresolver + +import org.springframework.core.MethodParameter +import org.springframework.web.client.RestClient +import org.springframework.web.client.support.RestClientAdapter +import org.springframework.web.service.annotation.GetExchange +import org.springframework.web.service.invoker.HttpRequestValues +import org.springframework.web.service.invoker.HttpServiceArgumentResolver +import org.springframework.web.service.invoker.HttpServiceProxyFactory + +class CustomHttpServiceArgumentResolver { + + // tag::httpinterface[] + interface RepositoryService { + + @GetExchange("/repos/search") + fun searchRepository(search: Search): List + + } + // end::httpinterface[] + + class Sample { + fun sample() { + // tag::usage[] + val restClient = RestClient.builder().baseUrl("https://api.github.com/").build() + val adapter = RestClientAdapter.create(restClient) + val factory = HttpServiceProxyFactory + .builderFor(adapter) + .customArgumentResolver(SearchQueryArgumentResolver()) + .build() + val repositoryService = factory.createClient(RepositoryService::class.java) + + val search = Search(owner = "spring-projects", language = "java", query = "rest") + val repositories = repositoryService.searchRepository(search) + // end::usage[] + repositories.size + } + } + + // tag::argumentresolver[] + class SearchQueryArgumentResolver : HttpServiceArgumentResolver { + override fun resolve( + argument: Any?, + parameter: MethodParameter, + requestValues: HttpRequestValues.Builder + ): Boolean { + if (parameter.getParameterType() == Search::class.java) { + val search = argument as Search + requestValues.addRequestParameter("owner", search.owner) + .addRequestParameter("language", search.language) + .addRequestParameter("query", search.query) + return true + } + return false + } + } + // end::argumentresolver[] + + data class Search(val query: String, val owner: String, val language: String) + + data class Repository(val name: String) +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/schedulingenableannotationsupport/SchedulingConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/schedulingenableannotationsupport/SchedulingConfiguration.kt new file mode 100644 index 000000000000..4faed328fb37 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/schedulingenableannotationsupport/SchedulingConfiguration.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.schedulingenableannotationsupport + +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.scheduling.annotation.EnableScheduling + +// tag::snippet[] +@Configuration +@EnableAsync +@EnableScheduling +class SchedulingConfiguration +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/schedulingtaskexecutorusage/LoggingTaskDecorator.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/schedulingtaskexecutorusage/LoggingTaskDecorator.kt new file mode 100644 index 000000000000..a9501bf7b83d --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/schedulingtaskexecutorusage/LoggingTaskDecorator.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.schedulingtaskexecutorusage + +import org.apache.commons.logging.Log +import org.apache.commons.logging.LogFactory +import org.springframework.core.task.TaskDecorator + +class LoggingTaskDecorator : TaskDecorator { + + override fun decorate(runnable: Runnable): Runnable { + return Runnable { + logger.debug("Before execution of $runnable") + runnable.run() + logger.debug("After execution of $runnable") + } + } + + companion object { + private val logger: Log = LogFactory.getLog( + LoggingTaskDecorator::class.java + ) + } +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorConfiguration.kt new file mode 100644 index 000000000000..dea4510ac937 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorConfiguration.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.schedulingtaskexecutorusage + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor + +@Configuration +class TaskExecutorConfiguration { + + // tag::snippet[] + @Bean + fun taskExecutor() = ThreadPoolTaskExecutor().apply { + corePoolSize = 5 + maxPoolSize = 10 + queueCapacity = 25 + } + + @Bean + fun taskExecutorExample(taskExecutor: ThreadPoolTaskExecutor) = TaskExecutorExample(taskExecutor) + // end::snippet[] + + // tag::decorator[] + @Bean + fun decoratedTaskExecutor() = ThreadPoolTaskExecutor().apply { + setTaskDecorator(LoggingTaskDecorator()) + } + // end::decorator[] +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorExample.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorExample.kt new file mode 100644 index 000000000000..4ec8a261dd9e --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorExample.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.integration.schedulingtaskexecutorusage + +import org.springframework.core.task.TaskExecutor + +// tag::snippet[] +class TaskExecutorExample(private val taskExecutor: TaskExecutor) { + + private inner class MessagePrinterTask(private val message: String) : Runnable { + override fun run() { + println(message) + } + } + + fun printMessages() { + for (i in 0..24) { + taskExecutor.execute( + MessagePrinterTask( + "Message$i" + ) + ) + } + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterassertions/HotelControllerTests.kt b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterassertions/HotelControllerTests.kt new file mode 100644 index 000000000000..851eb323ccac --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterassertions/HotelControllerTests.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterassertions + +import org.assertj.core.api.Assertions.assertThat +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.assertj.MockMvcTester + +class HotelControllerTests { + + private val mockMvc = MockMvcTester.of(HotelController()) + + fun getHotel() { + // tag::get[] + assertThat(mockMvc.get().uri("/hotels/{id}", 42)) + .hasStatusOk() + .hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .bodyJson().isLenientlyEqualTo("sample/hotel-42.json") + // end::get[] + } + + + fun getHotelInvalid() { + // tag::failure[] + assertThat(mockMvc.get().uri("/hotels/{id}", -1)) + .hasFailed() + .hasStatus(HttpStatus.BAD_REQUEST) + .failure().hasMessageContaining("Identifier should be positive") + // end::failure[] + } + + class HotelController +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterassertionsjson/FamilyControllerTests.kt b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterassertionsjson/FamilyControllerTests.kt new file mode 100644 index 000000000000..71faa576fc75 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterassertionsjson/FamilyControllerTests.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterassertionsjson + +import org.assertj.core.api.Assertions.* +import org.assertj.core.api.InstanceOfAssertFactories +import org.assertj.core.api.ThrowingConsumer +import org.springframework.test.web.servlet.assertj.MockMvcTester + +/** + * + * @author Stephane Nicoll + */ +class FamilyControllerTests { + + private val mockMvc = MockMvcTester.of(FamilyController()) + + + fun extractingPathAsMap() { + // tag::extract-asmap[] + assertThat(mockMvc.get().uri("/family")).bodyJson() + .extractingPath("$.members[0]") + .asMap() + .contains(entry("name", "Homer")) + // end::extract-asmap[] + } + + fun extractingPathAndConvertWithType() { + // tag::extract-convert[] + assertThat(mockMvc.get().uri("/family")).bodyJson() + .extractingPath("$.members[0]") + .convertTo(Member::class.java) + .satisfies(ThrowingConsumer { member: Member -> + assertThat(member.name).isEqualTo("Homer") + }) + // end::extract-convert[] + } + + fun extractingPathAndConvertWithAssertFactory() { + // tag::extract-convert-assert-factory[] + assertThat(mockMvc.get().uri("/family")).bodyJson() + .extractingPath("$.members") + .convertTo(InstanceOfAssertFactories.list(Member::class.java)) + .hasSize(5) + .element(0).satisfies(ThrowingConsumer { member: Member -> + assertThat(member.name).isEqualTo("Homer") + }) + // end::extract-convert-assert-factory[] + } + + fun assertTheSimpsons() { + // tag::assert-file[] + assertThat(mockMvc.get().uri("/family")).bodyJson() + .isStrictlyEqualTo("sample/simpsons.json") + // end::assert-file[] + } + + class FamilyController + + @JvmRecord + data class Member(val name: String) +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterintegration/HotelController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterintegration/HotelController.kt new file mode 100644 index 000000000000..923c3557a5f7 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterintegration/HotelController.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterintegration + +import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.* +import org.springframework.test.web.servlet.assertj.MockMvcTester +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* + +/** + * + * @author Stephane Nicoll + */ +class HotelController { + + private val mockMvc = MockMvcTester.of(HotelController()) + + + fun perform() { + // tag::perform[] + // Static import on MockMvcRequestBuilders.get + assertThat(mockMvc.perform(get("/hotels/{id}",42))) + .hasStatusOk() + // end::perform[] + } + + fun performWithCustomMatcher() { + // tag::perform[] + // Static import on MockMvcResultMatchers.status + assertThat(mockMvc.get().uri("/hotels/{id}", 42)) + .matches(status().isOk()) + // end::perform[] + } + + class HotelController +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequests/HotelControllerTests.kt b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequests/HotelControllerTests.kt new file mode 100644 index 000000000000..3b0167dc828c --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequests/HotelControllerTests.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterrequests + +import org.assertj.core.api.Assertions.assertThat +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.assertj.MockMvcTester + +class HotelControllerTests { + + private val mockMvc = MockMvcTester.of(HotelController()) + + fun createHotel() { + // tag::post[] + assertThat(mockMvc.post().uri("/hotels/{id}", 42).accept(MediaType.APPLICATION_JSON)) + . // ... + // end::post[] + hasStatusOk() + } + + fun createHotelMultipleAssertions() { + // tag::post-exchange[] + val result = mockMvc.post().uri("/hotels/{id}", 42) + .accept(MediaType.APPLICATION_JSON).exchange() + assertThat(result) + . // ... + // end::post-exchange[] + hasStatusOk() + } + + fun queryParameters() { + // tag::query-parameters[] + assertThat(mockMvc.get().uri("/hotels?thing={thing}", "somewhere")) + . // ... + //end::query-parameters[] + hasStatusOk() + } + + fun parameters() { + // tag::parameters[] + assertThat(mockMvc.get().uri("/hotels").param("thing", "somewhere")) + . // ... + // end::parameters[] + hasStatusOk() + } + + class HotelController +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestsasync/AsyncControllerTests.kt b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestsasync/AsyncControllerTests.kt new file mode 100644 index 000000000000..5dc065b4ddb6 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestsasync/AsyncControllerTests.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterrequestsasync + +import org.assertj.core.api.Assertions.assertThat +import org.springframework.test.web.servlet.assertj.MockMvcTester +import java.time.Duration + +class AsyncControllerTests { + + private val mockMvc = MockMvcTester.of(AsyncController()) + + fun asyncExchangeWithCustomTimeToWait() { + // tag::duration[] + assertThat(mockMvc.get().uri("/compute").exchange(Duration.ofSeconds(5))) + . // ... + // end::duration[] + hasStatusOk() + } + + class AsyncController +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestsmultipart/MultipartControllerTests.kt b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestsmultipart/MultipartControllerTests.kt new file mode 100644 index 000000000000..07762bd7c6bb --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestsmultipart/MultipartControllerTests.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterrequestsmultipart + +import org.assertj.core.api.Assertions.assertThat +import org.springframework.test.web.servlet.assertj.MockMvcTester +import java.nio.charset.StandardCharsets + +/** + * + * @author Stephane Nicoll + */ +class MultipartControllerTests { + + private val mockMvc = MockMvcTester.of(MultipartController()) + + fun multiPart() { + // tag::snippet[] + assertThat(mockMvc.post().uri("/upload").multipart() + .file("file1.txt", "Hello".toByteArray(StandardCharsets.UTF_8)) + .file("file2.txt", "World".toByteArray(StandardCharsets.UTF_8))) + . // ... + // end::snippet[] + hasStatusOk() + } + + class MultipartController +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestspaths/HotelControllerTests.kt b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestspaths/HotelControllerTests.kt new file mode 100644 index 000000000000..2be16eab1114 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctesterrequestspaths/HotelControllerTests.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctesterrequestspaths + +import org.assertj.core.api.Assertions.assertThat +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.assertj.MockMvcTester +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder + +class HotelControllerTests { + + private val mockMvc = MockMvcTester.of(HotelController()) + + fun contextAndServletPaths() { + // tag::context-servlet-paths[] + assertThat(mockMvc.get().uri("/app/main/hotels/{id}", 42) + .contextPath("/app").servletPath("/main")) + . // ... + // end::context-servlet-paths[] + hasStatusOk() + } + + fun configureMockMvcTesterWithDefaultSettings() { + // tag::default-customizations[] + val mockMvc = + MockMvcTester.of(listOf(HotelController())) { builder: StandaloneMockMvcBuilder -> + builder.defaultRequest( + MockMvcRequestBuilders.get("/") + .contextPath("/app").servletPath("/main") + .accept(MediaType.APPLICATION_JSON) + ).build() + } + // end::default-customizations[] + mockMvc.toString() // avoid warning + } + + + class HotelController +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountControllerIntegrationTests.kt b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountControllerIntegrationTests.kt new file mode 100644 index 000000000000..5f86ee6c8e05 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountControllerIntegrationTests.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctestersetup + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig +import org.springframework.test.web.servlet.assertj.MockMvcTester +import org.springframework.web.context.WebApplicationContext + +// tag::snippet[] +@SpringJUnitWebConfig(ApplicationWebConfiguration::class) +class AccountControllerIntegrationTests(@Autowired wac: WebApplicationContext) { + + private val mockMvc = MockMvcTester.from(wac) + + // ... + +} +// end::snippet[] + diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountControllerStandaloneTests.kt b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountControllerStandaloneTests.kt new file mode 100644 index 000000000000..2d10ec032aa9 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/AccountControllerStandaloneTests.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctestersetup + +import org.springframework.test.web.servlet.assertj.MockMvcTester + +// tag::snippet[] +class AccountControllerStandaloneTests { + + val mockMvc = MockMvcTester.of(AccountController()) + + // ... + +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/converter/AccountControllerIntegrationTests.kt b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/converter/AccountControllerIntegrationTests.kt new file mode 100644 index 000000000000..523d728941e4 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/testing/mockmvc/assertj/mockmvctestersetup/converter/AccountControllerIntegrationTests.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.testing.mockmvc.assertj.mockmvctestersetup.converter + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.docs.testing.mockmvc.assertj.mockmvctestersetup.ApplicationWebConfiguration +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig +import org.springframework.test.web.servlet.assertj.MockMvcTester +import org.springframework.web.context.WebApplicationContext + +// tag::snippet[] +@SpringJUnitWebConfig(ApplicationWebConfiguration::class) +class AccountControllerIntegrationTests(@Autowired wac: WebApplicationContext) { + + private val mockMvc = MockMvcTester.from(wac).withHttpMessageConverters( + listOf(wac.getBean(AbstractJackson2HttpMessageConverter::class.java))) + + // ... + +} +// end::snippet[] + diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/controller/webfluxanncontrollerexceptions/SimpleController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/controller/webfluxanncontrollerexceptions/SimpleController.kt new file mode 100644 index 000000000000..645aa1de0b4f --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/controller/webfluxanncontrollerexceptions/SimpleController.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webflux.controller.webfluxanncontrollerexceptions; + +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.ExceptionHandler +import java.io.IOException + +@Controller +class SimpleController { + + @ExceptionHandler(IOException::class) + fun handle() : ResponseEntity { + return ResponseEntity.internalServerError().body("Could not read file storage") + } + +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/controller/webfluxannexceptionhandlermedia/MediaTypeController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/controller/webfluxannexceptionhandlermedia/MediaTypeController.kt new file mode 100644 index 000000000000..3bddcc33f21a --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/controller/webfluxannexceptionhandlermedia/MediaTypeController.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webflux.controller.webfluxannexceptionhandlermedia + +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.ExceptionHandler + +@Controller +class MediaTypeController { + + // tag::mediatype[] + @ExceptionHandler(produces = ["application/json"]) + fun handleJson(exc: IllegalArgumentException): ResponseEntity { + return ResponseEntity.badRequest().body(ErrorMessage(exc.message, 42)) + } + + @ExceptionHandler(produces = ["text/html"]) + fun handle(exc: IllegalArgumentException, model: Model): String { + model.addAttribute("error", ErrorMessage(exc.message, 42)) + return "errorView" + } + // end::mediatype[] + + @JvmRecord + data class ErrorMessage(val message: String?, val code: Int) +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/filters/urlhandler/UrlHandlerFilterConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/filters/urlhandler/UrlHandlerFilterConfiguration.kt new file mode 100644 index 000000000000..bcfc050b1f41 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/filters/urlhandler/UrlHandlerFilterConfiguration.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webflux.filters.urlhandler + +import org.springframework.http.HttpStatus +import org.springframework.web.filter.reactive.UrlHandlerFilter + +class UrlHandlerFilterConfiguration { + + @Suppress("UNUSED_VARIABLE") + fun configureUrlHandlerFilter() { + // tag::config[] + val urlHandlerFilter = UrlHandlerFilter + // will HTTP 308 redirect "/blog/my-blog-post/" -> "/blog/my-blog-post" + .trailingSlashHandler("/blog/**").redirect(HttpStatus.PERMANENT_REDIRECT) + // will mutate the request to "/admin/user/account/" and make it as "/admin/user/account" + .trailingSlashHandler("/admin/**").mutateRequest() + .build() + // end::config[] + } +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/webfluxconfigpathmatching/WebConfig.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/webfluxconfigpathmatching/WebConfig.kt index b656ef7f5eb6..3194264eba6b 100644 --- a/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/webfluxconfigpathmatching/WebConfig.kt +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/webfluxconfigpathmatching/WebConfig.kt @@ -19,12 +19,10 @@ package org.springframework.docs.web.webflux.webfluxconfigpathmatching import org.springframework.context.annotation.Configuration import org.springframework.web.bind.annotation.RestController import org.springframework.web.method.HandlerTypePredicate -import org.springframework.web.reactive.config.EnableWebFlux import org.springframework.web.reactive.config.PathMatchConfigurer import org.springframework.web.reactive.config.WebFluxConfigurer @Configuration -@EnableWebFlux class WebConfig : WebFluxConfigurer { override fun configurePathMatching(configurer: PathMatchConfigurer) { diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/filters/urlhandler/UrlHandlerFilterConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/filters/urlhandler/UrlHandlerFilterConfiguration.kt new file mode 100644 index 000000000000..9714924e3737 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/filters/urlhandler/UrlHandlerFilterConfiguration.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.filters.urlhandler + +import org.springframework.http.HttpStatus +import org.springframework.web.filter.UrlHandlerFilter + +class UrlHandlerFilterConfiguration { + + @Suppress("UNUSED_VARIABLE") + fun configureUrlHandlerFilter() { + // tag::config[] + val urlHandlerFilter = UrlHandlerFilter + // will HTTP 308 redirect "/blog/my-blog-post/" -> "/blog/my-blog-post" + .trailingSlashHandler("/blog/**").redirect(HttpStatus.PERMANENT_REDIRECT) + // will wrap the request to "/admin/user/account/" and make it as "/admin/user/account" + .trailingSlashHandler("/admin/**").wrapRequest() + .build() + // end::config[] + } +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigadvancedjava/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigadvancedjava/WebConfiguration.kt new file mode 100644 index 000000000000..f5ee4887eb8f --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigadvancedjava/WebConfiguration.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigadvancedjava + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.DelegatingWebMvcConfiguration + +// tag::snippet[] +@Configuration +class WebConfiguration : DelegatingWebMvcConfiguration() { + + // ... +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigadvancedxml/MyPostProcessor.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigadvancedxml/MyPostProcessor.kt new file mode 100644 index 000000000000..c5628f27de41 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigadvancedxml/MyPostProcessor.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigadvancedxml + +import org.springframework.beans.factory.config.BeanPostProcessor +import org.springframework.stereotype.Component + +// tag::snippet[] +@Component +class MyPostProcessor : BeanPostProcessor { + + override fun postProcessBeforeInitialization(bean: Any, name: String): Any { + // ... + return bean + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcontentnegotiation/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcontentnegotiation/WebConfiguration.kt new file mode 100644 index 000000000000..50bd075660bf --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcontentnegotiation/WebConfiguration.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigcontentnegotiation + +import org.springframework.context.annotation.Configuration +import org.springframework.http.MediaType +import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +// tag::snippet[] +@Configuration +class WebConfiguration : WebMvcConfigurer { + + override fun configureContentNegotiation(configurer: ContentNegotiationConfigurer) { + configurer.mediaType("json", MediaType.APPLICATION_JSON) + configurer.mediaType("xml", MediaType.APPLICATION_XML) + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/DateTimeWebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/DateTimeWebConfiguration.kt new file mode 100644 index 000000000000..f77e14982ce9 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/DateTimeWebConfiguration.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigconversion + +import org.springframework.context.annotation.Configuration +import org.springframework.format.FormatterRegistry +import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + + +// tag::snippet[] +@Configuration +class DateTimeWebConfiguration : WebMvcConfigurer { + + override fun addFormatters(registry: FormatterRegistry) { + DateTimeFormatterRegistrar().apply { + setUseIsoFormat(true) + registerFormatters(registry) + } + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/WebConfiguration.kt new file mode 100644 index 000000000000..534fa04b8cdc --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/WebConfiguration.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigconversion + +import org.springframework.context.annotation.Configuration +import org.springframework.format.FormatterRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +// tag::snippet[] +@Configuration +class WebConfiguration : WebMvcConfigurer { + + override fun addFormatters(registry: FormatterRegistry) { + // ... + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcustomize/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcustomize/WebConfiguration.kt new file mode 100644 index 000000000000..485b9f71e02a --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcustomize/WebConfiguration.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigcustomize + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +// tag::snippet[] +@Configuration +class WebConfiguration : WebMvcConfigurer { + + // Implement configuration methods... +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigenable/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigenable/WebConfiguration.kt new file mode 100644 index 000000000000..5fe920100b57 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigenable/WebConfiguration.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigenable + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.EnableWebMvc + +// tag::snippet[] +@Configuration +@EnableWebMvc +class WebConfiguration { +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfiginterceptors/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfiginterceptors/WebConfiguration.kt new file mode 100644 index 000000000000..c2f6d8daba16 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfiginterceptors/WebConfiguration.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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. + */ + +@file:Suppress("DEPRECATION") +package org.springframework.docs.web.webmvc.mvcconfig.mvcconfiginterceptors + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.InterceptorRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor +import org.springframework.web.servlet.theme.ThemeChangeInterceptor + +// tag::snippet[] +@Configuration +class WebConfiguration : WebMvcConfigurer { + + override fun addInterceptors(registry: InterceptorRegistry) { + registry.addInterceptor(LocaleChangeInterceptor()) + registry.addInterceptor(ThemeChangeInterceptor()).addPathPatterns("/**").excludePathPatterns("/admin/**") + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigmessageconverters/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigmessageconverters/WebConfiguration.kt new file mode 100644 index 000000000000..12c197a46f51 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigmessageconverters/WebConfiguration.kt @@ -0,0 +1,25 @@ +package org.springframework.docs.web.webmvc.mvcconfig.mvcconfigmessageconverters + +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule +import org.springframework.context.annotation.Configuration +import org.springframework.http.converter.HttpMessageConverter +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter +import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import java.text.SimpleDateFormat + +// tag::snippet[] +@Configuration +class WebConfiguration : WebMvcConfigurer { + + override fun configureMessageConverters(converters: MutableList>) { + val builder = Jackson2ObjectMapperBuilder() + .indentOutput(true) + .dateFormat(SimpleDateFormat("yyyy-MM-dd")) + .modulesToInstall(ParameterNamesModule()) + converters.add(MappingJackson2HttpMessageConverter(builder.build())) + converters.add(MappingJackson2XmlHttpMessageConverter(builder.createXmlMapper(true).build())) + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigpathmatching/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigpathmatching/WebConfiguration.kt new file mode 100644 index 000000000000..1ee4be3095cd --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigpathmatching/WebConfiguration.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigpathmatching + +import org.springframework.context.annotation.Configuration +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.method.HandlerTypePredicate +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import org.springframework.web.util.pattern.PathPatternParser + +// tag::snippet[] +@Configuration +class WebConfiguration : WebMvcConfigurer { + + override fun configurePathMatch(configurer: PathMatchConfigurer) { + configurer.addPathPrefix("/api", HandlerTypePredicate.forAnnotation(RestController::class.java)) + } + + fun patternParser(): PathPatternParser { + val pathPatternParser = PathPatternParser() + //... + return pathPatternParser + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/VersionedConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/VersionedConfiguration.kt new file mode 100644 index 000000000000..5cb39227e946 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/VersionedConfiguration.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigstaticresources + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import org.springframework.web.servlet.resource.VersionResourceResolver + +// tag::snippet[] +@Configuration +class VersionedConfiguration : WebMvcConfigurer { + + override fun addResourceHandlers(registry: ResourceHandlerRegistry) { + registry.addResourceHandler("/resources/**") + .addResourceLocations("/public/") + .resourceChain(true) + .addResolver(VersionResourceResolver().addContentVersionStrategy("/**")) + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/WebConfiguration.kt new file mode 100644 index 000000000000..72c91e87f177 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/WebConfiguration.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigstaticresources + +import org.springframework.context.annotation.Configuration +import org.springframework.http.CacheControl +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import java.time.Duration + +// tag::snippet[] +@Configuration +class WebConfiguration : WebMvcConfigurer { + + override fun addResourceHandlers(registry: ResourceHandlerRegistry) { + registry.addResourceHandler("/resources/**") + .addResourceLocations("/public", "classpath:/static/") + .setCacheControl(CacheControl.maxAge(Duration.ofDays(365))) + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/MyController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/MyController.kt new file mode 100644 index 000000000000..6d2522c48d47 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/MyController.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigvalidation + +import org.springframework.stereotype.Controller +import org.springframework.web.bind.WebDataBinder +import org.springframework.web.bind.annotation.InitBinder + +// tag::snippet[] +@Controller +class MyController { + + @InitBinder + fun initBinder(binder: WebDataBinder) { + binder.addValidators(FooValidator()) + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/WebConfiguration.kt new file mode 100644 index 000000000000..23f2f5d4a267 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/WebConfiguration.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigvalidation + +import org.springframework.context.annotation.Configuration +import org.springframework.validation.Validator +import org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +// tag::snippet[] +@Configuration +class WebConfiguration : WebMvcConfigurer { + + override fun getValidator(): Validator { + val validator = OptionalValidatorFactoryBean() + // ... + return validator + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewcontroller/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewcontroller/WebConfiguration.kt new file mode 100644 index 000000000000..69ade9721866 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewcontroller/WebConfiguration.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigviewcontroller + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +// tag::snippet[] +@Configuration +class WebConfiguration : WebMvcConfigurer { + + override fun addViewControllers(registry: ViewControllerRegistry) { + registry.addViewController("/").setViewName("home") + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/FreeMarkerConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/FreeMarkerConfiguration.kt new file mode 100644 index 000000000000..55acaa63cb11 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/FreeMarkerConfiguration.kt @@ -0,0 +1,24 @@ +package org.springframework.docs.web.webmvc.mvcconfig.mvcconfigviewresolvers + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.ViewResolverRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer +import org.springframework.web.servlet.view.json.MappingJackson2JsonView + +// tag::snippet[] +@Configuration +class FreeMarkerConfiguration : WebMvcConfigurer { + + override fun configureViewResolvers(registry: ViewResolverRegistry) { + registry.enableContentNegotiation(MappingJackson2JsonView()) + registry.freeMarker().cache(false) + } + + @Bean + fun freeMarkerConfigurer() = FreeMarkerConfigurer().apply { + setTemplateLoaderPath("/freemarker") + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/WebConfiguration.kt new file mode 100644 index 000000000000..472ecf25bf30 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/WebConfiguration.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcconfigviewresolvers + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.ViewResolverRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import org.springframework.web.servlet.view.json.MappingJackson2JsonView + +// tag::snippet[] +@Configuration +class WebConfiguration : WebMvcConfigurer { + override fun configureViewResolvers(registry: ViewResolverRegistry) { + registry.enableContentNegotiation(MappingJackson2JsonView()) + registry.jsp() + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/CustomDefaultServletConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/CustomDefaultServletConfiguration.kt new file mode 100644 index 000000000000..648beac23750 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/CustomDefaultServletConfiguration.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcdefaultservlethandler + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +// tag::snippet[] +@Configuration +class CustomDefaultServletConfiguration : WebMvcConfigurer { + + override fun configureDefaultServletHandling(configurer: DefaultServletHandlerConfigurer) { + configurer.enable("myCustomDefaultServlet") + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/WebConfiguration.kt new file mode 100644 index 000000000000..a217953aa0fd --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/WebConfiguration.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvcconfig.mvcdefaultservlethandler + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +// tag::snippet[] +@Configuration +class WebConfiguration : WebMvcConfigurer { + + override fun configureDefaultServletHandling(configurer: DefaultServletHandlerConfigurer) { + configurer.enable() + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcanncontroller/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcanncontroller/WebConfiguration.kt new file mode 100644 index 000000000000..410b77ff06f4 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcanncontroller/WebConfiguration.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvccontroller.mvcanncontroller + +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration + +// tag::snippet[] +@Configuration +@ComponentScan("org.example.web") +class WebConfiguration { + + // ... +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandler/SimpleController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandler/SimpleController.kt new file mode 100644 index 000000000000..f89f36d05977 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandler/SimpleController.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvccontroller.mvcannexceptionhandler; + +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.ExceptionHandler +import java.io.IOException + +@Controller +class SimpleController { + + @ExceptionHandler(IOException::class) + fun handle() : ResponseEntity { + return ResponseEntity.internalServerError().body("Could not read file storage") + } + +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlerexc/ExceptionController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlerexc/ExceptionController.kt new file mode 100644 index 000000000000..548ed6e56814 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlerexc/ExceptionController.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvccontroller.mvcannexceptionhandlerexc + +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.ExceptionHandler +import java.io.IOException +import java.rmi.RemoteException + +@Controller +class ExceptionController { + + // tag::narrow[] + @ExceptionHandler(FileSystemException::class, RemoteException::class) + fun handleIoException(ex: IOException): ResponseEntity { + return ResponseEntity.internalServerError().body(ex.message) + } + // end::narrow[] + + + // tag::general[] + @ExceptionHandler(FileSystemException::class, RemoteException::class) + fun handleExceptions(ex: Exception): ResponseEntity { + return ResponseEntity.internalServerError().body(ex.message) + } + // end::general[] +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlermedia/MediaTypeController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlermedia/MediaTypeController.kt new file mode 100644 index 000000000000..e1311de33ea3 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlermedia/MediaTypeController.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.webmvc.mvccontroller.mvcannexceptionhandlermedia + +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.ExceptionHandler + +@Controller +class MediaTypeController { + + // tag::mediatype[] + @ExceptionHandler(produces = ["application/json"]) + fun handleJson(exc: IllegalArgumentException): ResponseEntity { + return ResponseEntity.badRequest().body(ErrorMessage(exc.message, 42)) + } + + @ExceptionHandler(produces = ["text/html"]) + fun handle(exc: IllegalArgumentException, model: Model): String { + model.addAttribute("error", ErrorMessage(exc.message, 42)) + return "errorView" + } + // end::mediatype[] + + @JvmRecord + data class ErrorMessage(val message: String?, val code: Int) +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompauthenticationtokenbased/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompauthenticationtokenbased/WebSocketConfiguration.kt new file mode 100644 index 000000000000..d03e7e0995d4 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompauthenticationtokenbased/WebSocketConfiguration.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompauthenticationtokenbased + +import org.springframework.context.annotation.Configuration +import org.springframework.messaging.Message +import org.springframework.messaging.MessageChannel +import org.springframework.messaging.simp.config.ChannelRegistration +import org.springframework.messaging.simp.stomp.StompCommand +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.messaging.support.ChannelInterceptor +import org.springframework.messaging.support.MessageHeaderAccessor +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfiguration : WebSocketMessageBrokerConfigurer { + + override fun configureClientInboundChannel(registration: ChannelRegistration) { + registration.interceptors(object : ChannelInterceptor { + override fun preSend(message: Message<*>, channel: MessageChannel): Message<*> { + val accessor = MessageHeaderAccessor.getAccessor(message, + StompHeaderAccessor::class.java) + if (StompCommand.CONNECT == accessor!!.command) { + // Access authentication header(s) and invoke accessor.setUser(user) + } + return message + } + }) + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/MessageSizeLimitWebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/MessageSizeLimitWebSocketConfiguration.kt new file mode 100644 index 000000000000..b8affe8c5ee0 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/MessageSizeLimitWebSocketConfiguration.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompconfigurationperformance + +import org.springframework.context.annotation.Configuration +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class MessageSizeLimitWebSocketConfiguration : WebSocketMessageBrokerConfigurer { + + override fun configureWebSocketTransport(registration: WebSocketTransportRegistration) { + registration.setMessageSizeLimit(128 * 1024) + } + + // ... +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/WebSocketConfiguration.kt new file mode 100644 index 000000000000..ecdd126a33ff --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/WebSocketConfiguration.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompconfigurationperformance + +import org.springframework.context.annotation.Configuration +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfiguration : WebSocketMessageBrokerConfigurer { + + override fun configureWebSocketTransport(registration: WebSocketTransportRegistration) { + registration.setSendTimeLimit(15 * 1000).setSendBufferSizeLimit(512 * 1024) + } + + // ... +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/RedController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/RedController.kt new file mode 100644 index 000000000000..88ebf88248fa --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/RedController.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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. + */ + +@file:Suppress("UNUSED_PARAMETER") +package org.springframework.docs.web.websocket.stomp.websocketstompdestinationseparator + +import org.springframework.messaging.handler.annotation.DestinationVariable +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.stereotype.Controller + +// tag::snippet[] +@Controller +@MessageMapping("red") +class RedController { + + @MessageMapping("blue.{green}") + fun handleGreen(@DestinationVariable green: String) { + // ... + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/WebSocketConfiguration.kt new file mode 100644 index 000000000000..0e0502a6501c --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/WebSocketConfiguration.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompdestinationseparator + +import org.springframework.context.annotation.Configuration +import org.springframework.messaging.simp.config.MessageBrokerRegistry +import org.springframework.util.AntPathMatcher +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfiguration : WebSocketMessageBrokerConfigurer { + + // ... + + override fun configureMessageBroker(registry: MessageBrokerRegistry) { + registry.setPathMatcher(AntPathMatcher(".")) + registry.enableStompBrokerRelay("/queue", "/topic") + registry.setApplicationDestinationPrefixes("/app") + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompenable/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompenable/WebSocketConfiguration.kt new file mode 100644 index 000000000000..554d70962155 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompenable/WebSocketConfiguration.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompenable + +import org.springframework.context.annotation.Configuration +import org.springframework.messaging.simp.config.MessageBrokerRegistry +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.StompEndpointRegistry +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfiguration : WebSocketMessageBrokerConfigurer { + + override fun registerStompEndpoints(registry: StompEndpointRegistry) { + // /portfolio is the HTTP URL for the endpoint to which a WebSocket (or SockJS) + // client needs to connect for the WebSocket handshake + registry.addEndpoint("/portfolio") + } + + override fun configureMessageBroker(config: MessageBrokerRegistry) { + // STOMP messages whose destination header begins with /app are routed to + // @MessageMapping methods in @Controller classes + config.setApplicationDestinationPrefixes("/app") + // Use the built-in message broker for subscriptions and broadcasting and + // route messages whose destination header begins with /topic or /queue to the broker + config.enableSimpleBroker("/topic", "/queue") + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelay/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelay/WebSocketConfiguration.kt new file mode 100644 index 000000000000..aa3630b5c693 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelay/WebSocketConfiguration.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstomphandlebrokerrelay + +import org.springframework.context.annotation.Configuration +import org.springframework.messaging.simp.config.MessageBrokerRegistry +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.StompEndpointRegistry +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfiguration : WebSocketMessageBrokerConfigurer { + + override fun registerStompEndpoints(registry: StompEndpointRegistry) { + registry.addEndpoint("/portfolio").withSockJS() + } + + override fun configureMessageBroker(registry: MessageBrokerRegistry) { + registry.enableStompBrokerRelay("/topic", "/queue") + registry.setApplicationDestinationPrefixes("/app") + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelayconfigure/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelayconfigure/WebSocketConfiguration.kt new file mode 100644 index 000000000000..1fb61d28b8bb --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelayconfigure/WebSocketConfiguration.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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. + */ + +@file:Suppress("DEPRECATION") +package org.springframework.docs.web.websocket.stomp.websocketstomphandlebrokerrelayconfigure + +import org.springframework.context.annotation.Configuration +import org.springframework.messaging.simp.config.MessageBrokerRegistry +import org.springframework.messaging.simp.stomp.StompReactorNettyCodec +import org.springframework.messaging.tcp.reactor.ReactorNettyTcpClient +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer +import java.net.InetSocketAddress + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfiguration : WebSocketMessageBrokerConfigurer { + + // ... + + override fun configureMessageBroker(registry: MessageBrokerRegistry) { + registry.enableStompBrokerRelay("/queue/", "/topic/").setTcpClient(createTcpClient()) + registry.setApplicationDestinationPrefixes("/app") + } + + private fun createTcpClient(): ReactorNettyTcpClient { + return ReactorNettyTcpClient({ it.addressSupplier { InetSocketAddress(0) } }, StompReactorNettyCodec()) + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomphandlesimplebroker/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomphandlesimplebroker/WebSocketConfiguration.kt new file mode 100644 index 000000000000..4602aaee2089 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomphandlesimplebroker/WebSocketConfiguration.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstomphandlesimplebroker + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Lazy +import org.springframework.messaging.simp.config.MessageBrokerRegistry +import org.springframework.scheduling.TaskScheduler +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfiguration : WebSocketMessageBrokerConfigurer { + + private lateinit var messageBrokerTaskScheduler: TaskScheduler + + @Autowired + fun setMessageBrokerTaskScheduler(@Lazy taskScheduler: TaskScheduler) { + this.messageBrokerTaskScheduler = taskScheduler + } + + override fun configureMessageBroker(registry: MessageBrokerRegistry) { + registry.enableSimpleBroker("/queue/", "/topic/") + .setHeartbeatValue(longArrayOf(10000, 20000)) + .setTaskScheduler(messageBrokerTaskScheduler) + + // ... + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompinterceptors/MyChannelInterceptor.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompinterceptors/MyChannelInterceptor.kt new file mode 100644 index 000000000000..45c7f93e1301 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompinterceptors/MyChannelInterceptor.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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. + */ + +@file:Suppress("UNUSED_VARIABLE") +package org.springframework.docs.web.websocket.stomp.websocketstompinterceptors + +import org.springframework.messaging.Message +import org.springframework.messaging.MessageChannel +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.messaging.support.ChannelInterceptor + +// tag::snippet[] +class MyChannelInterceptor : ChannelInterceptor { + + override fun preSend(message: Message<*>, channel: MessageChannel): Message<*> { + val accessor = StompHeaderAccessor.wrap(message) + val command = accessor.command + // ... + return message + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompinterceptors/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompinterceptors/WebSocketConfiguration.kt new file mode 100644 index 000000000000..2516b8d73018 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompinterceptors/WebSocketConfiguration.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompinterceptors + +import org.springframework.context.annotation.Configuration +import org.springframework.messaging.simp.config.ChannelRegistration +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfiguration : WebSocketMessageBrokerConfigurer { + + override fun configureClientInboundChannel(registration: ChannelRegistration) { + registration.interceptors(MyChannelInterceptor()) + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompmessageflow/GreetingController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompmessageflow/GreetingController.kt new file mode 100644 index 000000000000..ab24a3f16d87 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompmessageflow/GreetingController.kt @@ -0,0 +1,21 @@ +package org.springframework.docs.web.websocket.stomp.websocketstompmessageflow + +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.stereotype.Controller +import java.text.SimpleDateFormat +import java.util.* + +// tag::snippet[] +@Controller +class GreetingController { + + @MessageMapping("/greeting") + fun handle(greeting: String): String { + return "[${getTimestamp()}: $greeting" + } + + private fun getTimestamp(): String { + return SimpleDateFormat("MM/dd/yyyy h:mm:ss a").format(Date()) + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompmessageflow/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompmessageflow/WebSocketConfiguration.kt new file mode 100644 index 000000000000..e464312da142 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompmessageflow/WebSocketConfiguration.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompmessageflow + +import org.springframework.context.annotation.Configuration +import org.springframework.messaging.simp.config.MessageBrokerRegistry +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.StompEndpointRegistry +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer + + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfiguration : WebSocketMessageBrokerConfigurer { + override fun registerStompEndpoints(registry: StompEndpointRegistry) { + registry.addEndpoint("/portfolio") + } + + override fun configureMessageBroker(registry: MessageBrokerRegistry) { + registry.setApplicationDestinationPrefixes("/app") + registry.enableSimpleBroker("/topic") + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/PublishOrderWebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/PublishOrderWebSocketConfiguration.kt new file mode 100644 index 000000000000..74c6d508e6bf --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/PublishOrderWebSocketConfiguration.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstomporderedmessages + +import org.springframework.context.annotation.Configuration +import org.springframework.messaging.simp.config.MessageBrokerRegistry +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class PublishOrderWebSocketConfiguration : WebSocketMessageBrokerConfigurer { + + override fun configureMessageBroker(registry: MessageBrokerRegistry) { + // ... + registry.setPreservePublishOrder(true) + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/ReceiveOrderWebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/ReceiveOrderWebSocketConfiguration.kt new file mode 100644 index 000000000000..a5324bbe0b2c --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/ReceiveOrderWebSocketConfiguration.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstomporderedmessages + +import org.springframework.context.annotation.Configuration +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.StompEndpointRegistry +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class ReceiveOrderWebSocketConfiguration : WebSocketMessageBrokerConfigurer { + + override fun registerStompEndpoints(registry: StompEndpointRegistry) { + registry.setPreserveReceiveOrder(true) + } +} +// end::snippet[] \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompserverconfig/JettyWebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompserverconfig/JettyWebSocketConfiguration.kt new file mode 100644 index 000000000000..75420c84ac44 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompserverconfig/JettyWebSocketConfiguration.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompserverconfig + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.StompEndpointRegistry +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer +import org.springframework.web.socket.server.jetty.JettyRequestUpgradeStrategy +import org.springframework.web.socket.server.support.DefaultHandshakeHandler +import java.time.Duration + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class JettyWebSocketConfiguration : WebSocketMessageBrokerConfigurer { + + override fun registerStompEndpoints(registry: StompEndpointRegistry) { + registry.addEndpoint("/portfolio").setHandshakeHandler(handshakeHandler()) + } + + @Bean + fun handshakeHandler(): DefaultHandshakeHandler { + val strategy = JettyRequestUpgradeStrategy() + strategy.addWebSocketConfigurer { + it.inputBufferSize = 4 * 8192 + it.idleTimeout = Duration.ofSeconds(600) + } + return DefaultHandshakeHandler(strategy) + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompserverconfig/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompserverconfig/WebSocketConfiguration.kt new file mode 100644 index 000000000000..50b6916fe866 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/stomp/websocketstompserverconfig/WebSocketConfiguration.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.stomp.websocketstompserverconfig + +import org.springframework.context.annotation.Configuration +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration + +// tag::snippet[] +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfiguration : WebSocketMessageBrokerConfigurer { + + override fun configureWebSocketTransport(registry: WebSocketTransportRegistration) { + registry.setMessageSizeLimit(4 * 8192) + registry.setTimeToFirstMessage(30000) + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketfallbacksockjsenable/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketfallbacksockjsenable/WebSocketConfiguration.kt new file mode 100644 index 000000000000..432e8044882f --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketfallbacksockjsenable/WebSocketConfiguration.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketfallbacksockjsenable + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.docs.web.websocket.websocketserverhandler.MyHandler +import org.springframework.web.socket.WebSocketHandler +import org.springframework.web.socket.config.annotation.EnableWebSocket +import org.springframework.web.socket.config.annotation.WebSocketConfigurer +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry + +// tag::snippet[] +@Configuration +@EnableWebSocket +class WebSocketConfiguration : WebSocketConfigurer { + override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { + registry.addHandler(myHandler(), "/myHandler").withSockJS() + } + + @Bean + fun myHandler(): WebSocketHandler { + return MyHandler() + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverallowedorigins/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverallowedorigins/WebSocketConfiguration.kt new file mode 100644 index 000000000000..79f80e690df1 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverallowedorigins/WebSocketConfiguration.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketserverallowedorigins + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.docs.web.websocket.websocketserverhandler.MyHandler +import org.springframework.web.socket.WebSocketHandler +import org.springframework.web.socket.config.annotation.EnableWebSocket +import org.springframework.web.socket.config.annotation.WebSocketConfigurer +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry + +// tag::snippet[] +@Configuration +@EnableWebSocket +class WebSocketConfiguration : WebSocketConfigurer { + + override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { + registry.addHandler(myHandler(), "/myHandler").setAllowedOrigins("https://mydomain.com") + } + + @Bean + fun myHandler(): WebSocketHandler { + return MyHandler() + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverhandler/MyHandler.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverhandler/MyHandler.kt new file mode 100644 index 000000000000..38b53e01b646 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverhandler/MyHandler.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketserverhandler + +import org.springframework.web.socket.TextMessage +import org.springframework.web.socket.WebSocketSession +import org.springframework.web.socket.handler.TextWebSocketHandler + +// tag::snippet[] +class MyHandler : TextWebSocketHandler() { + + override fun handleTextMessage(session: WebSocketSession, message: TextMessage) { + // ... + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverhandler/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverhandler/WebSocketConfiguration.kt new file mode 100644 index 000000000000..5217d325d165 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverhandler/WebSocketConfiguration.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketserverhandler + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.socket.WebSocketHandler +import org.springframework.web.socket.config.annotation.EnableWebSocket +import org.springframework.web.socket.config.annotation.WebSocketConfigurer +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry + +// tag::snippet[] +@Configuration +@EnableWebSocket +class WebSocketConfiguration : WebSocketConfigurer { + + override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { + registry.addHandler(myHandler(), "/myHandler") + } + + @Bean + fun myHandler(): WebSocketHandler { + return MyHandler() + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverhandshake/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverhandshake/WebSocketConfiguration.kt new file mode 100644 index 000000000000..fd6d5fd1c62f --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverhandshake/WebSocketConfiguration.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketserverhandshake + +import org.springframework.context.annotation.Configuration +import org.springframework.docs.web.websocket.websocketserverhandler.MyHandler +import org.springframework.web.socket.config.annotation.EnableWebSocket +import org.springframework.web.socket.config.annotation.WebSocketConfigurer +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry +import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor + +// tag::snippet[] +@Configuration +@EnableWebSocket +class WebSocketConfiguration : WebSocketConfigurer { + + override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { + registry.addHandler(MyHandler(), "/myHandler") + .addInterceptors(HttpSessionHandshakeInterceptor()) + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/JettyWebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/JettyWebSocketConfiguration.kt new file mode 100644 index 000000000000..0c2faf491a8f --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/JettyWebSocketConfiguration.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketserverruntimeconfiguration + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.socket.WebSocketHandler +import org.springframework.web.socket.config.annotation.EnableWebSocket +import org.springframework.web.socket.config.annotation.WebSocketConfigurer +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry +import org.springframework.web.socket.server.jetty.JettyRequestUpgradeStrategy +import org.springframework.web.socket.server.support.DefaultHandshakeHandler +import java.time.Duration + +// tag::snippet[] +@Configuration +@EnableWebSocket +class JettyWebSocketConfiguration : WebSocketConfigurer { + + override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { + registry.addHandler(echoWebSocketHandler(), "/echo").setHandshakeHandler(handshakeHandler()) + } + + @Bean + fun echoWebSocketHandler(): WebSocketHandler { + return MyEchoHandler() + } + + @Bean + fun handshakeHandler(): DefaultHandshakeHandler { + val strategy = JettyRequestUpgradeStrategy() + strategy.addWebSocketConfigurer { + it.inputBufferSize = 8192 + it.idleTimeout = Duration.ofSeconds(600) + } + return DefaultHandshakeHandler(strategy) + } +} +// end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/WebSocketConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/WebSocketConfiguration.kt new file mode 100644 index 000000000000..32f4357a3eea --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/WebSocketConfiguration.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.docs.web.websocket.websocketserverruntimeconfiguration + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean + +// tag::snippet[] +@Configuration +class WebSocketConfiguration { + + @Bean + fun createWebSocketContainer() = ServletServerContainerFactoryBean().apply { + maxTextMessageBufferSize = 8192 + maxBinaryMessageBufferSize = 8192 + } +} +// end::snippet[] diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/aop/aopajltwspring/ApplicationConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/core/aop/aopajltwspring/ApplicationConfiguration.xml new file mode 100644 index 000000000000..395a8cfe7c5a --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/aop/aopajltwspring/ApplicationConfiguration.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/aop/aopajltwspring/CustomWeaverConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/core/aop/aopajltwspring/CustomWeaverConfiguration.xml new file mode 100644 index 000000000000..0837f96099a7 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/aop/aopajltwspring/CustomWeaverConfiguration.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/aop/aopatconfigurable/ApplicationConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/core/aop/aopatconfigurable/ApplicationConfiguration.xml new file mode 100644 index 000000000000..8472b707e543 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/aop/aopatconfigurable/ApplicationConfiguration.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/aop/ataspectj/aopaspectjsupport/ApplicationConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/core/aop/ataspectj/aopaspectjsupport/ApplicationConfiguration.xml new file mode 100644 index 000000000000..736824791b81 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/aop/ataspectj/aopaspectjsupport/ApplicationConfiguration.xml @@ -0,0 +1,13 @@ + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/aop/ataspectj/aopataspectj/ApplicationConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/core/aop/ataspectj/aopataspectj/ApplicationConfiguration.xml new file mode 100644 index 000000000000..b591dd381ff1 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/aop/ataspectj/aopataspectj/ApplicationConfiguration.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ApplicationConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ApplicationConfiguration.xml new file mode 100644 index 000000000000..b232cce47330 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/aop/ataspectj/aopataspectjexample/ApplicationConfiguration.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/aopapi/aopapipointcutsregex/JdkRegexpConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/core/aopapi/aopapipointcutsregex/JdkRegexpConfiguration.xml new file mode 100644 index 000000000000..d008155a71d0 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/aopapi/aopapipointcutsregex/JdkRegexpConfiguration.xml @@ -0,0 +1,20 @@ + + + + + + + + .*set.* + .*absquatulate + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/aopapi/aopapipointcutsregex/RegexpConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/core/aopapi/aopapipointcutsregex/RegexpConfiguration.xml new file mode 100644 index 000000000000..a4fe96e5d10a --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/aopapi/aopapipointcutsregex/RegexpConfiguration.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + .*set.* + .*absquatulate + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/ApplicationConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/ApplicationConfiguration.xml new file mode 100644 index 000000000000..bf12e98874db --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/ApplicationConfiguration.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/LazyConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/LazyConfiguration.xml new file mode 100644 index 000000000000..45e1ccb184af --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/beans/dependencies/beansfactorylazyinit/LazyConfiguration.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/expressions/expressionsbeandef/MovieRecommender.xml b/framework-docs/src/main/resources/org/springframework/docs/core/expressions/expressionsbeandef/MovieRecommender.xml new file mode 100644 index 000000000000..a700e176d812 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/expressions/expressionsbeandef/MovieRecommender.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/expressions/expressionsbeandef/PropertyValueTestBean.xml b/framework-docs/src/main/resources/org/springframework/docs/core/expressions/expressionsbeandef/PropertyValueTestBean.xml new file mode 100644 index 000000000000..d1bdade08dae --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/expressions/expressionsbeandef/PropertyValueTestBean.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/expressions/expressionsbeandef/ShapeGuess.xml b/framework-docs/src/main/resources/org/springframework/docs/core/expressions/expressionsbeandef/ShapeGuess.xml new file mode 100644 index 000000000000..c7703c38eacb --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/expressions/expressionsbeandef/ShapeGuess.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/validation/formatconfiguringformattingglobaldatetimeformat/ApplicationConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/core/validation/formatconfiguringformattingglobaldatetimeformat/ApplicationConfiguration.xml new file mode 100644 index 000000000000..7e5072e6302b --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/validation/formatconfiguringformattingglobaldatetimeformat/ApplicationConfiguration.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/validation/validationbeanvalidationspringmethod/ApplicationConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/core/validation/validationbeanvalidationspringmethod/ApplicationConfiguration.xml new file mode 100644 index 000000000000..2d0fc9029411 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/validation/validationbeanvalidationspringmethod/ApplicationConfiguration.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/core/validation/validationbeanvalidationspringmethodexceptions/ApplicationConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/core/validation/validationbeanvalidationspringmethodexceptions/ApplicationConfiguration.xml new file mode 100644 index 000000000000..c7477ce108d4 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/core/validation/validationbeanvalidationspringmethodexceptions/ApplicationConfiguration.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/BasicDataSourceConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/BasicDataSourceConfiguration.xml new file mode 100644 index 000000000000..a859292df7e5 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/BasicDataSourceConfiguration.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/ComboPooledDataSourceConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/ComboPooledDataSourceConfiguration.xml new file mode 100644 index 000000000000..cf0e6c58a214 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/ComboPooledDataSourceConfiguration.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/DriverManagerDataSourceConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/DriverManagerDataSourceConfiguration.xml new file mode 100644 index 000000000000..c385240d733f --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcdatasource/DriverManagerDataSourceConfiguration.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcembeddeddatabase/JdbcEmbeddedDatabaseConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcembeddeddatabase/JdbcEmbeddedDatabaseConfiguration.xml new file mode 100644 index 000000000000..7968814c94f1 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcembeddeddatabase/JdbcEmbeddedDatabaseConfiguration.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDaoConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDaoConfiguration.xml new file mode 100644 index 000000000000..1feeebf5334b --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventDaoConfiguration.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventRepositoryConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventRepositoryConfiguration.xml new file mode 100644 index 000000000000..5f92e7ca5655 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/dataaccess/jdbc/jdbcjdbctemplateidioms/JdbcCorporateEventRepositoryConfiguration.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cacheannotationenable/CacheConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cacheannotationenable/CacheConfiguration.xml new file mode 100644 index 000000000000..af465a230cc3 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cacheannotationenable/CacheConfiguration.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CacheConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CacheConfiguration.xml new file mode 100644 index 000000000000..47d890614882 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CacheConfiguration.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CustomCacheConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CustomCacheConfiguration.xml new file mode 100644 index 000000000000..9ce1f5af47a0 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationcaffeine/CustomCacheConfiguration.xml @@ -0,0 +1,17 @@ + + + + + + + default + books + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationjdk/CacheConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationjdk/CacheConfiguration.xml new file mode 100644 index 000000000000..3aadd751dc6b --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationjdk/CacheConfiguration.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationjsr107/CacheConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationjsr107/CacheConfiguration.xml new file mode 100644 index 000000000000..6231658aa2cb --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationjsr107/CacheConfiguration.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationnoop/CacheConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationnoop/CacheConfiguration.xml new file mode 100644 index 000000000000..a87f3da9f7fd --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/cache/cachestoreconfigurationnoop/CacheConfiguration.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsannotatedsupport/JmsConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsannotatedsupport/JmsConfiguration.xml new file mode 100644 index 000000000000..42d597832ce6 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsannotatedsupport/JmsConfiguration.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/AlternativeJmsConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/AlternativeJmsConfiguration.xml new file mode 100644 index 000000000000..2cb1532c67a1 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/AlternativeJmsConfiguration.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/JmsConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/JmsConfiguration.xml new file mode 100644 index 000000000000..a14ad0bff8cf --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsjcamessageendpointmanager/JmsConfiguration.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsreceivingasync/JmsConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsreceivingasync/JmsConfiguration.xml new file mode 100644 index 000000000000..c7690e43470a --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsreceivingasync/JmsConfiguration.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/JmsConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/JmsConfiguration.xml new file mode 100644 index 000000000000..6471dad0f162 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/JmsConfiguration.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageListenerConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageListenerConfiguration.xml new file mode 100644 index 000000000000..e2fab9723c10 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmsreceivingasyncmessagelisteneradapter/MessageListenerConfiguration.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmstxparticipation/ExternalTxJmsConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmstxparticipation/ExternalTxJmsConfiguration.xml new file mode 100644 index 000000000000..07668236f95e --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmstxparticipation/ExternalTxJmsConfiguration.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmstxparticipation/JmsConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmstxparticipation/JmsConfiguration.xml new file mode 100644 index 000000000000..e0c89287a6af --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/jms/jmstxparticipation/JmsConfiguration.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/CustomJmxConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/CustomJmxConfiguration.xml new file mode 100644 index 000000000000..5a3017cfc845 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/CustomJmxConfiguration.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/JmxConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/JmxConfiguration.xml new file mode 100644 index 000000000000..c1b7fb50d3e2 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/jmx/jmxcontextmbeanexport/JmxConfiguration.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/jmx/jmxexporting/JmxConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/jmx/jmxexporting/JmxConfiguration.xml new file mode 100644 index 000000000000..692265fa6027 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/jmx/jmxexporting/JmxConfiguration.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/mailusagesimple/MailConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/mailusagesimple/MailConfiguration.xml new file mode 100644 index 000000000000..a2259b165a19 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/mailusagesimple/MailConfiguration.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/schedulingenableannotationsupport/SchedulingConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/schedulingenableannotationsupport/SchedulingConfiguration.xml new file mode 100644 index 000000000000..aeb0b970a608 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/schedulingenableannotationsupport/SchedulingConfiguration.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorConfiguration.xml new file mode 100644 index 000000000000..96fc1be4e904 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/integration/schedulingtaskexecutorusage/TaskExecutorConfiguration.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcontentnegotiation/WebConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcontentnegotiation/WebConfiguration.xml new file mode 100644 index 000000000000..5faef120118e --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigcontentnegotiation/WebConfiguration.xml @@ -0,0 +1,23 @@ + + + + + + + + + + json=application/json + xml=application/xml + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/WebConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/WebConfiguration.xml new file mode 100644 index 000000000000..f0bf33102ad5 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigconversion/WebConfiguration.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigenable/WebConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigenable/WebConfiguration.xml new file mode 100644 index 000000000000..da68dad89ac8 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigenable/WebConfiguration.xml @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfiginterceptors/WebConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfiginterceptors/WebConfiguration.xml new file mode 100644 index 000000000000..2d0f1cae1ead --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfiginterceptors/WebConfiguration.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigmessageconverters/WebConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigmessageconverters/WebConfiguration.xml new file mode 100644 index 000000000000..f63d2aab1ff3 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigmessageconverters/WebConfiguration.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigpathmatching/WebConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigpathmatching/WebConfiguration.xml new file mode 100644 index 000000000000..b19e510a5822 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigpathmatching/WebConfiguration.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/VersionedConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/VersionedConfiguration.xml new file mode 100644 index 000000000000..685b2a4be0c8 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/VersionedConfiguration.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/WebConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/WebConfiguration.xml new file mode 100644 index 000000000000..24883846eddd --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigstaticresources/WebConfiguration.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/WebConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/WebConfiguration.xml new file mode 100644 index 000000000000..782b3cadce80 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigvalidation/WebConfiguration.xml @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewcontroller/WebConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewcontroller/WebConfiguration.xml new file mode 100644 index 000000000000..097ca62890c5 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewcontroller/WebConfiguration.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/FreeMarkerConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/FreeMarkerConfiguration.xml new file mode 100644 index 000000000000..d019b5533535 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/FreeMarkerConfiguration.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/WebConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/WebConfiguration.xml new file mode 100644 index 000000000000..f6dba12f1f1f --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigviewresolvers/WebConfiguration.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/CustomDefaultServletConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/CustomDefaultServletConfiguration.xml new file mode 100644 index 000000000000..d6ae9519abfc --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/CustomDefaultServletConfiguration.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/WebConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/WebConfiguration.xml new file mode 100644 index 000000000000..a00ad1073ae7 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvcconfig/mvcdefaultservlethandler/WebConfiguration.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvccontroller/mvcanncontroller/WebConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvccontroller/mvcanncontroller/WebConfiguration.xml new file mode 100644 index 000000000000..e3ceacddb01b --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/webmvc/mvccontroller/mvcanncontroller/WebConfiguration.xml @@ -0,0 +1,17 @@ + + + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/MessageSizeLimitWebSocketConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/MessageSizeLimitWebSocketConfiguration.xml new file mode 100644 index 000000000000..2379035dafbb --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/MessageSizeLimitWebSocketConfiguration.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/WebSocketConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/WebSocketConfiguration.xml new file mode 100644 index 000000000000..0926960c9c8a --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstompconfigurationperformance/WebSocketConfiguration.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/WebSocketConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/WebSocketConfiguration.xml new file mode 100644 index 000000000000..60f3334ff4a4 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstompdestinationseparator/WebSocketConfiguration.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstompenable/WebSocketConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstompenable/WebSocketConfiguration.xml new file mode 100644 index 000000000000..910ef94886a9 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstompenable/WebSocketConfiguration.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelay/WebSocketConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelay/WebSocketConfiguration.xml new file mode 100644 index 000000000000..0edc2c44994c --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstomphandlebrokerrelay/WebSocketConfiguration.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/PublishOrderWebSocketConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/PublishOrderWebSocketConfiguration.xml new file mode 100644 index 000000000000..976e6ca573c9 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/stomp/websocketstomporderedmessages/PublishOrderWebSocketConfiguration.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketfallbacksockjsenable/WebSocketConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketfallbacksockjsenable/WebSocketConfiguration.xml new file mode 100644 index 000000000000..81ce03b7f491 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketfallbacksockjsenable/WebSocketConfiguration.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketserverallowedorigins/WebSocketConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketserverallowedorigins/WebSocketConfiguration.xml new file mode 100644 index 000000000000..5736abfcb1f0 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketserverallowedorigins/WebSocketConfiguration.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketserverhandler/WebSocketConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketserverhandler/WebSocketConfiguration.xml new file mode 100644 index 000000000000..e400f9eb73ee --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketserverhandler/WebSocketConfiguration.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketserverhandshake/WebSocketConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketserverhandshake/WebSocketConfiguration.xml new file mode 100644 index 000000000000..b7bffe51c774 --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketserverhandshake/WebSocketConfiguration.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/WebSocketConfiguration.xml b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/WebSocketConfiguration.xml new file mode 100644 index 000000000000..c4e067b264cf --- /dev/null +++ b/framework-docs/src/main/resources/org/springframework/docs/web/websocket/websocketserverruntimeconfiguration/WebSocketConfiguration.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 844089b50f0e..2f85e36bd4ea 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -7,32 +7,32 @@ javaPlatform { } dependencies { - api(platform("com.fasterxml.jackson:jackson-bom:2.15.4")) - api(platform("io.micrometer:micrometer-bom:1.12.12")) - api(platform("io.netty:netty-bom:4.1.115.Final")) + api(platform("com.fasterxml.jackson:jackson-bom:2.18.2")) + api(platform("io.micrometer:micrometer-bom:1.14.4")) + api(platform("io.netty:netty-bom:4.1.118.Final")) api(platform("io.netty:netty5-bom:5.0.0.Alpha5")) - api(platform("io.projectreactor:reactor-bom:2023.0.12")) - api(platform("io.rsocket:rsocket-bom:1.1.4")) + api(platform("io.projectreactor:reactor-bom:2024.0.3")) + api(platform("io.rsocket:rsocket-bom:1.1.5")) api(platform("org.apache.groovy:groovy-bom:4.0.24")) api(platform("org.apache.logging.log4j:log4j-bom:2.21.1")) - api(platform("org.assertj:assertj-bom:3.26.3")) - api(platform("org.eclipse.jetty:jetty-bom:12.0.15")) - api(platform("org.eclipse.jetty.ee10:jetty-ee10-bom:12.0.15")) - api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3")) + api(platform("org.assertj:assertj-bom:3.27.3")) + api(platform("org.eclipse.jetty:jetty-bom:12.0.16")) + api(platform("org.eclipse.jetty.ee10:jetty-ee10-bom:12.0.16")) + api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1")) api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.3")) - api(platform("org.junit:junit-bom:5.10.5")) - api(platform("org.mockito:mockito-bom:5.12.0")) + api(platform("org.junit:junit-bom:5.11.4")) + api(platform("org.mockito:mockito-bom:5.15.2")) constraints { api("com.fasterxml:aalto-xml:1.3.2") - api("com.fasterxml.woodstox:woodstox-core:6.6.2") + api("com.fasterxml.woodstox:woodstox-core:6.7.0") api("com.github.ben-manes.caffeine:caffeine:3.1.8") api("com.github.librepdf:openpdf:1.3.43") api("com.google.code.findbugs:findbugs:3.0.1") api("com.google.code.findbugs:jsr305:3.0.2") - api("com.google.code.gson:gson:2.10.1") - api("com.google.protobuf:protobuf-java-util:3.25.3") - api("com.h2database:h2:2.2.224") + api("com.google.code.gson:gson:2.11.0") + api("com.google.protobuf:protobuf-java-util:4.29.3") + api("com.h2database:h2:2.3.232") api("com.jayway.jsonpath:json-path:2.9.0") api("com.oracle.database.jdbc:ojdbc11:21.9.0.0") api("com.rometools:rome:1.19.0") @@ -44,7 +44,7 @@ dependencies { api("com.sun.xml.bind:jaxb-impl:3.0.2") api("com.sun.xml.bind:jaxb-xjc:3.0.2") api("com.thoughtworks.qdox:qdox:2.1.0") - api("com.thoughtworks.xstream:xstream:1.4.20") + api("com.thoughtworks.xstream:xstream:1.4.21") api("commons-io:commons-io:2.15.0") api("de.bechte.junit:junit-hierarchicalcontextrunner:4.12.2") api("io.micrometer:context-propagation:1.1.1") @@ -54,11 +54,11 @@ dependencies { api("io.r2dbc:r2dbc-h2:1.0.0.RELEASE") api("io.r2dbc:r2dbc-spi-test:1.0.0.RELEASE") api("io.r2dbc:r2dbc-spi:1.0.0.RELEASE") - api("io.reactivex.rxjava3:rxjava:3.1.9") + api("io.reactivex.rxjava3:rxjava:3.1.10") api("io.smallrye.reactive:mutiny:1.10.0") - api("io.undertow:undertow-core:2.3.17.Final") - api("io.undertow:undertow-servlet:2.3.17.Final") - api("io.undertow:undertow-websockets-jsr:2.3.17.Final") + api("io.undertow:undertow-core:2.3.18.Final") + api("io.undertow:undertow-servlet:2.3.18.Final") + api("io.undertow:undertow-websockets-jsr:2.3.18.Final") api("io.vavr:vavr:0.10.4") api("jakarta.activation:jakarta.activation-api:2.0.1") api("jakarta.annotation:jakarta.annotation-api:2.0.0") @@ -90,7 +90,6 @@ dependencies { api("jaxen:jaxen:1.2.0") api("junit:junit:4.13.2") api("net.sf.jopt-simple:jopt-simple:5.0.4") - api("net.sourceforge.htmlunit:htmlunit:2.70.0") api("org.apache-extras.beanshell:bsh:2.0b6") api("org.apache.activemq:activemq-broker:5.17.6") api("org.apache.activemq:activemq-kahadb-store:5.17.6") @@ -116,6 +115,7 @@ dependencies { api("org.codehaus.jettison:jettison:1.5.4") api("org.crac:crac:1.4.0") api("org.dom4j:dom4j:2.1.4") + api("org.easymock:easymock:5.4.0") api("org.eclipse.jetty:jetty-reactive-httpclient:4.0.8") api("org.eclipse.persistence:org.eclipse.persistence.jpa:3.0.4") api("org.eclipse:yasson:2.0.4") @@ -129,7 +129,8 @@ dependencies { api("org.hamcrest:hamcrest:2.2") api("org.hibernate:hibernate-core-jakarta:5.6.15.Final") api("org.hibernate:hibernate-validator:7.0.5.Final") - api("org.hsqldb:hsqldb:2.7.2") + api("org.hsqldb:hsqldb:2.7.4") + api("org.htmlunit:htmlunit:4.6.0") api("org.javamoney:moneta:1.4.4") api("org.jruby:jruby:9.4.9.0") api("org.junit.support:testng-engine:1.0.5") @@ -137,15 +138,16 @@ dependencies { api("org.ogce:xpp3:1.1.6") api("org.python:jython-standalone:2.7.3") api("org.quartz-scheduler:quartz:2.3.2") - api("org.seleniumhq.selenium:htmlunit-driver:2.70.0") - api("org.seleniumhq.selenium:selenium-java:3.141.59") + api("org.seleniumhq.selenium:htmlunit3-driver:4.26.0") + api("org.seleniumhq.selenium:selenium-java:4.26.0") api("org.skyscreamer:jsonassert:1.5.3") api("org.slf4j:slf4j-api:2.0.16") - api("org.testng:testng:7.9.0") + api("org.testng:testng:7.11.0") api("org.webjars:underscorejs:1.8.3") api("org.webjars:webjars-locator-core:0.55") - api("org.xmlunit:xmlunit-assertj:2.9.1") - api("org.xmlunit:xmlunit-matchers:2.9.1") - api("org.yaml:snakeyaml:2.2") + api("org.webjars:webjars-locator-lite:1.0.0") + api("org.xmlunit:xmlunit-assertj:2.10.0") + api("org.xmlunit:xmlunit-matchers:2.10.0") + api("org.yaml:snakeyaml:2.3") } } diff --git a/gradle.properties b/gradle.properties index 531361dfa877..d94d24e87fa3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=6.1.16-SNAPSHOT +version=6.2.3 org.gradle.caching=true org.gradle.jvmargs=-Xmx2048m diff --git a/gradle/spring-module.gradle b/gradle/spring-module.gradle index b327b0f2f25f..ff48a66e39e0 100644 --- a/gradle/spring-module.gradle +++ b/gradle/spring-module.gradle @@ -6,12 +6,15 @@ apply plugin: 'org.springframework.build.optional-dependencies' // apply plugin: 'io.github.goooler.shadow' apply plugin: 'me.champeau.jmh' apply from: "$rootDir/gradle/publications.gradle" +apply plugin: 'net.ltgt.errorprone' dependencies { jmh 'org.openjdk.jmh:jmh-core:1.37' jmh 'org.openjdk.jmh:jmh-generator-annprocess:1.37' jmh 'org.openjdk.jmh:jmh-generator-bytecode:1.37' jmh 'net.sf.jopt-simple:jopt-simple' + errorprone 'com.uber.nullaway:nullaway:0.10.26' + errorprone 'com.google.errorprone:error_prone_core:2.9.0' } pluginManager.withPlugin("kotlin") { @@ -109,3 +112,18 @@ publishing { // Disable publication of test fixture artifacts. components.java.withVariantsFromConfiguration(configurations.testFixturesApiElements) { skip() } components.java.withVariantsFromConfiguration(configurations.testFixturesRuntimeElements) { skip() } + +tasks.withType(JavaCompile).configureEach { + options.errorprone { + disableAllChecks = true + option("NullAway:CustomContractAnnotations", "org.springframework.lang.Contract") + option("NullAway:AnnotatedPackages", "org.springframework") + option("NullAway:UnannotatedSubPackages", "org.springframework.instrument,org.springframework.context.index," + + "org.springframework.asm,org.springframework.cglib,org.springframework.objenesis," + + "org.springframework.javapoet,org.springframework.aot.nativex.substitution,org.springframework.aot.nativex.feature") + } +} +tasks.compileJava { + // The check defaults to a warning, bump it up to an error for the main sources + options.errorprone.error("NullAway") +} \ No newline at end of file diff --git a/gradle/toolchains.gradle b/gradle/toolchains.gradle index 152abb08db45..8c5d248136da 100644 --- a/gradle/toolchains.gradle +++ b/gradle/toolchains.gradle @@ -55,8 +55,9 @@ plugins.withType(JavaPlugin).configureEach { languageVersion = testLanguageVersion } // Enable Java experimental support in Bytebuddy - // Remove when JDK 22 is supported by Mockito - if (testLanguageVersion == JavaLanguageVersion.of(22)) { + // Bytebuddy 1.15.4 supports JDK <= 24 + // see https://github.com/raphw/byte-buddy/blob/master/release-notes.md + if (testLanguageVersion.compareTo(JavaLanguageVersion.of(24)) > 0 ) { jvmArgs("-Dnet.bytebuddy.experimental=true") } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df97d72b8b91..cea7a793a84b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f5feea6d6b11..f3b75f3b0d4f 100755 --- a/gradlew +++ b/gradlew @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/integration-tests/src/test/kotlin/org/springframework/aop/framework/autoproxy/AspectJAutoProxyInterceptorKotlinIntegrationTests.kt b/integration-tests/src/test/kotlin/org/springframework/aop/framework/autoproxy/AspectJAutoProxyInterceptorKotlinIntegrationTests.kt index e14042f8badd..7cbd35532bd7 100644 --- a/integration-tests/src/test/kotlin/org/springframework/aop/framework/autoproxy/AspectJAutoProxyInterceptorKotlinIntegrationTests.kt +++ b/integration-tests/src/test/kotlin/org/springframework/aop/framework/autoproxy/AspectJAutoProxyInterceptorKotlinIntegrationTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/settings.gradle b/settings.gradle index 3bc6898a5ba3..20be17f8e087 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,5 @@ plugins { - id "com.gradle.develocity" version "3.17.2" + id "com.gradle.develocity" version "3.19" id "io.spring.ge.conventions" version "0.0.17" id "org.gradle.toolchains.foojay-resolver-convention" version "0.7.0" } diff --git a/spring-aop/src/main/java/org/aopalliance/intercept/ConstructorInterceptor.java b/spring-aop/src/main/java/org/aopalliance/intercept/ConstructorInterceptor.java index 08b02a502fa2..a814d1f8a7e8 100644 --- a/spring-aop/src/main/java/org/aopalliance/intercept/ConstructorInterceptor.java +++ b/spring-aop/src/main/java/org/aopalliance/intercept/ConstructorInterceptor.java @@ -23,7 +23,7 @@ * *

The user should implement the {@link * #construct(ConstructorInvocation)} method to modify the original - * behavior. E.g. the following class implements a singleton + * behavior. For example, the following class implements a singleton * interceptor (allows only one unique instance for the intercepted * class): * diff --git a/spring-aop/src/main/java/org/aopalliance/intercept/MethodInterceptor.java b/spring-aop/src/main/java/org/aopalliance/intercept/MethodInterceptor.java index 9188e25e1d0d..a601fba50c6d 100644 --- a/spring-aop/src/main/java/org/aopalliance/intercept/MethodInterceptor.java +++ b/spring-aop/src/main/java/org/aopalliance/intercept/MethodInterceptor.java @@ -24,7 +24,7 @@ * are nested "on top" of the target. * *

The user should implement the {@link #invoke(MethodInvocation)} - * method to modify the original behavior. E.g. the following class + * method to modify the original behavior. For example, the following class * implements a tracing interceptor (traces all the calls on the * intercepted method(s)): * diff --git a/spring-aop/src/main/java/org/springframework/aop/Pointcut.java b/spring-aop/src/main/java/org/springframework/aop/Pointcut.java index ffcf92ef316c..fc860df1c360 100644 --- a/spring-aop/src/main/java/org/springframework/aop/Pointcut.java +++ b/spring-aop/src/main/java/org/springframework/aop/Pointcut.java @@ -21,7 +21,7 @@ * *

A pointcut is composed of a {@link ClassFilter} and a {@link MethodMatcher}. * Both these basic terms and a Pointcut itself can be combined to build up combinations - * (e.g. through {@link org.springframework.aop.support.ComposablePointcut}). + * (for example, through {@link org.springframework.aop.support.ComposablePointcut}). * * @author Rod Johnson * @see ClassFilter diff --git a/spring-aop/src/main/java/org/springframework/aop/ThrowsAdvice.java b/spring-aop/src/main/java/org/springframework/aop/ThrowsAdvice.java index ef50fe8b8263..f6e453b40e3a 100644 --- a/spring-aop/src/main/java/org/springframework/aop/ThrowsAdvice.java +++ b/spring-aop/src/main/java/org/springframework/aop/ThrowsAdvice.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2008 the original author or authors. + * Copyright 2002-2024 the original author 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,11 +27,11 @@ *

Some examples of valid methods would be: * *

public void afterThrowing(Exception ex)
- *
public void afterThrowing(RemoteException)
+ *
public void afterThrowing(RemoteException ex)
*
public void afterThrowing(Method method, Object[] args, Object target, Exception ex)
*
public void afterThrowing(Method method, Object[] args, Object target, ServletException ex)
* - * The first three arguments are optional, and only useful if we want further + *

The first three arguments are optional, and only useful if we want further * information about the joinpoint, as in AspectJ after-throwing advice. * *

Note: If a throws-advice method throws an exception itself, it will diff --git a/spring-aop/src/main/java/org/springframework/aop/aspectj/AbstractAspectJAdvice.java b/spring-aop/src/main/java/org/springframework/aop/aspectj/AbstractAspectJAdvice.java index bbe397880b10..f6278b109709 100644 --- a/spring-aop/src/main/java/org/springframework/aop/aspectj/AbstractAspectJAdvice.java +++ b/spring-aop/src/main/java/org/springframework/aop/aspectj/AbstractAspectJAdvice.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -276,14 +276,18 @@ public void setArgumentNamesFromStringArray(String... argumentNames) { } if (this.aspectJAdviceMethod.getParameterCount() == this.argumentNames.length + 1) { // May need to add implicit join point arg name... - Class firstArgType = this.aspectJAdviceMethod.getParameterTypes()[0]; - if (firstArgType == JoinPoint.class || - firstArgType == ProceedingJoinPoint.class || - firstArgType == JoinPoint.StaticPart.class) { - String[] oldNames = this.argumentNames; - this.argumentNames = new String[oldNames.length + 1]; - this.argumentNames[0] = "THIS_JOIN_POINT"; - System.arraycopy(oldNames, 0, this.argumentNames, 1, oldNames.length); + for (int i = 0; i < this.aspectJAdviceMethod.getParameterCount(); i++) { + Class argType = this.aspectJAdviceMethod.getParameterTypes()[i]; + if (argType == JoinPoint.class || + argType == ProceedingJoinPoint.class || + argType == JoinPoint.StaticPart.class) { + String[] oldNames = this.argumentNames; + this.argumentNames = new String[oldNames.length + 1]; + System.arraycopy(oldNames, 0, this.argumentNames, 0, i); + this.argumentNames[i] = "THIS_JOIN_POINT"; + System.arraycopy(oldNames, i, this.argumentNames, i + 1, oldNames.length - i); + break; + } } } } @@ -552,6 +556,7 @@ private void configurePointcutParameters(String[] argumentNames, int argumentInd * @param ex the exception thrown by the method execution (may be null) * @return the empty array if there are no arguments */ + @SuppressWarnings("NullAway") protected Object[] argBinding(JoinPoint jp, @Nullable JoinPointMatch jpMatch, @Nullable Object returnValue, @Nullable Throwable ex) { diff --git a/spring-aop/src/main/java/org/springframework/aop/aspectj/AspectJExpressionPointcut.java b/spring-aop/src/main/java/org/springframework/aop/aspectj/AspectJExpressionPointcut.java index 0b18fefea3ee..aeb258e0a3b5 100644 --- a/spring-aop/src/main/java/org/springframework/aop/aspectj/AspectJExpressionPointcut.java +++ b/spring-aop/src/main/java/org/springframework/aop/aspectj/AspectJExpressionPointcut.java @@ -16,15 +16,11 @@ package org.springframework.aop.aspectj; -import java.io.IOException; -import java.io.ObjectInputStream; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.Arrays; -import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import org.aopalliance.intercept.MethodInvocation; import org.apache.commons.logging.Log; @@ -123,8 +119,6 @@ public class AspectJExpressionPointcut extends AbstractExpressionPointcut private transient boolean pointcutParsingFailed = false; - private transient Map shadowMatchCache = new ConcurrentHashMap<>(32); - /** * Create a new default AspectJExpressionPointcut. @@ -473,72 +467,65 @@ private ShadowMatch getTargetShadowMatch(Method method, Class targetClass) { } private ShadowMatch getShadowMatch(Method targetMethod, Method originalMethod) { - // Avoid lock contention for known Methods through concurrent access... - ShadowMatch shadowMatch = this.shadowMatchCache.get(targetMethod); + ShadowMatch shadowMatch = ShadowMatchUtils.getShadowMatch(this, targetMethod); if (shadowMatch == null) { - synchronized (this.shadowMatchCache) { - // Not found - now check again with full lock... - PointcutExpression fallbackExpression = null; - shadowMatch = this.shadowMatchCache.get(targetMethod); - if (shadowMatch == null) { - Method methodToMatch = targetMethod; + PointcutExpression fallbackExpression = null; + Method methodToMatch = targetMethod; + try { + try { + shadowMatch = obtainPointcutExpression().matchesMethodExecution(methodToMatch); + } + catch (ReflectionWorldException ex) { + // Failed to introspect target method, probably because it has been loaded + // in a special ClassLoader. Let's try the declaring ClassLoader instead... try { - try { - shadowMatch = obtainPointcutExpression().matchesMethodExecution(methodToMatch); - } - catch (ReflectionWorldException ex) { - // Failed to introspect target method, probably because it has been loaded - // in a special ClassLoader. Let's try the declaring ClassLoader instead... - try { - fallbackExpression = getFallbackPointcutExpression(methodToMatch.getDeclaringClass()); - if (fallbackExpression != null) { - shadowMatch = fallbackExpression.matchesMethodExecution(methodToMatch); - } - } - catch (ReflectionWorldException ex2) { - fallbackExpression = null; - } - } - if (targetMethod != originalMethod && (shadowMatch == null || - (Proxy.isProxyClass(targetMethod.getDeclaringClass()) && - (shadowMatch.neverMatches() || containsAnnotationPointcut())))) { - // Fall back to the plain original method in case of no resolvable match or a - // negative match on a proxy class (which doesn't carry any annotations on its - // redeclared methods), as well as for annotation pointcuts. - methodToMatch = originalMethod; - try { - shadowMatch = obtainPointcutExpression().matchesMethodExecution(methodToMatch); - } - catch (ReflectionWorldException ex) { - // Could neither introspect the target class nor the proxy class -> - // let's try the original method's declaring class before we give up... - try { - fallbackExpression = getFallbackPointcutExpression(methodToMatch.getDeclaringClass()); - if (fallbackExpression != null) { - shadowMatch = fallbackExpression.matchesMethodExecution(methodToMatch); - } - } - catch (ReflectionWorldException ex2) { - fallbackExpression = null; - } - } + fallbackExpression = getFallbackPointcutExpression(methodToMatch.getDeclaringClass()); + if (fallbackExpression != null) { + shadowMatch = fallbackExpression.matchesMethodExecution(methodToMatch); } } - catch (Throwable ex) { - // Possibly AspectJ 1.8.10 encountering an invalid signature - logger.debug("PointcutExpression matching rejected target method", ex); + catch (ReflectionWorldException ex2) { fallbackExpression = null; } - if (shadowMatch == null) { - shadowMatch = new ShadowMatchImpl(org.aspectj.util.FuzzyBoolean.NO, null, null, null); + } + if (targetMethod != originalMethod && (shadowMatch == null || + (Proxy.isProxyClass(targetMethod.getDeclaringClass()) && + (shadowMatch.neverMatches() || containsAnnotationPointcut())))) { + // Fall back to the plain original method in case of no resolvable match or a + // negative match on a proxy class (which doesn't carry any annotations on its + // redeclared methods), as well as for annotation pointcuts. + methodToMatch = originalMethod; + try { + shadowMatch = obtainPointcutExpression().matchesMethodExecution(methodToMatch); } - else if (shadowMatch.maybeMatches() && fallbackExpression != null) { - shadowMatch = new DefensiveShadowMatch(shadowMatch, - fallbackExpression.matchesMethodExecution(methodToMatch)); + catch (ReflectionWorldException ex) { + // Could neither introspect the target class nor the proxy class -> + // let's try the original method's declaring class before we give up... + try { + fallbackExpression = getFallbackPointcutExpression(methodToMatch.getDeclaringClass()); + if (fallbackExpression != null) { + shadowMatch = fallbackExpression.matchesMethodExecution(methodToMatch); + } + } + catch (ReflectionWorldException ex2) { + fallbackExpression = null; + } } - this.shadowMatchCache.put(targetMethod, shadowMatch); } } + catch (Throwable ex) { + // Possibly AspectJ 1.8.10 encountering an invalid signature + logger.debug("PointcutExpression matching rejected target method", ex); + fallbackExpression = null; + } + if (shadowMatch == null) { + shadowMatch = new ShadowMatchImpl(org.aspectj.util.FuzzyBoolean.NO, null, null, null); + } + else if (shadowMatch.maybeMatches() && fallbackExpression != null) { + shadowMatch = new DefensiveShadowMatch(shadowMatch, + fallbackExpression.matchesMethodExecution(methodToMatch)); + } + shadowMatch = ShadowMatchUtils.setShadowMatch(this, targetMethod, shadowMatch); } return shadowMatch; } @@ -594,19 +581,6 @@ public String toString() { return sb.toString(); } - //--------------------------------------------------------------------- - // Serialization support - //--------------------------------------------------------------------- - - private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException { - // Rely on default serialization, just initialize state after deserialization. - ois.defaultReadObject(); - - // Initialize transient fields. - // pointcutExpression will be initialized lazily by checkReadyToMatch() - this.shadowMatchCache = new ConcurrentHashMap<>(32); - } - /** * Handler for the Spring-specific {@code bean()} pointcut designator diff --git a/spring-aop/src/main/java/org/springframework/aop/aspectj/ShadowMatchUtils.java b/spring-aop/src/main/java/org/springframework/aop/aspectj/ShadowMatchUtils.java new file mode 100644 index 000000000000..beb3ac63bb96 --- /dev/null +++ b/spring-aop/src/main/java/org/springframework/aop/aspectj/ShadowMatchUtils.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.aop.aspectj; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.aspectj.weaver.tools.ShadowMatch; + +import org.springframework.aop.support.ExpressionPointcut; +import org.springframework.lang.Nullable; + +/** + * Internal {@link ShadowMatch} utilities. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public abstract class ShadowMatchUtils { + + private static final Map shadowMatchCache = new ConcurrentHashMap<>(256); + + /** + * Clear the cache of computed {@link ShadowMatch} instances. + */ + public static void clearCache() { + shadowMatchCache.clear(); + } + + /** + * Return the {@link ShadowMatch} for the specified {@link ExpressionPointcut} + * and {@link Method} or {@code null} if none is found. + * @param expression the expression + * @param method the method + * @return the {@code ShadowMatch} to use for the specified expression and method + */ + @Nullable + static ShadowMatch getShadowMatch(ExpressionPointcut expression, Method method) { + return shadowMatchCache.get(new Key(expression, method)); + } + + /** + * Associate the {@link ShadowMatch} to the specified {@link ExpressionPointcut} + * and method. If an entry already exists, the given {@code shadowMatch} is + * ignored. + * @param expression the expression + * @param method the method + * @param shadowMatch the shadow match to use for this expression and method + * if none already exists + * @return the shadow match to use for the specified expression and method + */ + static ShadowMatch setShadowMatch(ExpressionPointcut expression, Method method, ShadowMatch shadowMatch) { + ShadowMatch existing = shadowMatchCache.putIfAbsent(new Key(expression, method), shadowMatch); + return (existing != null ? existing : shadowMatch); + } + + + private record Key(ExpressionPointcut expression, Method method) {} + +} diff --git a/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/AnnotationAwareAspectJAutoProxyCreator.java b/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/AnnotationAwareAspectJAutoProxyCreator.java index 45ea4983644c..926392693e31 100644 --- a/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/AnnotationAwareAspectJAutoProxyCreator.java +++ b/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/AnnotationAwareAspectJAutoProxyCreator.java @@ -103,7 +103,7 @@ protected boolean isInfrastructureClass(Class beanClass) { // broad an impact. Instead we now override isInfrastructureClass to avoid proxying // aspects. I'm not entirely happy with that as there is no good reason not // to advise aspects, except that it causes advice invocation to go through a - // proxy, and if the aspect implements e.g the Ordered interface it will be + // proxy, and if the aspect implements, for example, the Ordered interface it will be // proxied by that interface and fail at runtime as the advice method is not // defined on the interface. We could potentially relax the restriction about // not advising aspects in the future. diff --git a/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/AspectMetadata.java b/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/AspectMetadata.java index a70aed625cc9..2775bc9fd32c 100644 --- a/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/AspectMetadata.java +++ b/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/AspectMetadata.java @@ -160,7 +160,7 @@ public String getAspectName() { /** * Return a Spring pointcut expression for a singleton aspect. - * (e.g. {@code Pointcut.TRUE} if it's a singleton). + * (for example, {@code Pointcut.TRUE} if it's a singleton). */ public Pointcut getPerClausePointcut() { return this.perClausePointcut; diff --git a/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/BeanFactoryAspectInstanceFactory.java b/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/BeanFactoryAspectInstanceFactory.java index 3bdbb9c16abd..28d5aa13e50f 100644 --- a/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/BeanFactoryAspectInstanceFactory.java +++ b/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/BeanFactoryAspectInstanceFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -77,7 +77,7 @@ public BeanFactoryAspectInstanceFactory(BeanFactory beanFactory, String name, @N this.beanFactory = beanFactory; this.name = name; Class resolvedType = type; - if (type == null) { + if (resolvedType == null) { resolvedType = beanFactory.getType(name); Assert.notNull(resolvedType, "Unresolvable bean type - explicitly specify the aspect class"); } @@ -109,13 +109,8 @@ public Object getAspectCreationMutex() { // Rely on singleton semantics provided by the factory -> no local lock. return null; } - else if (this.beanFactory instanceof ConfigurableBeanFactory cbf) { - // No singleton guarantees from the factory -> let's lock locally but - // reuse the factory's singleton lock, just in case a lazy dependency - // of our advice bean happens to trigger the singleton lock implicitly... - return cbf.getSingletonMutex(); - } else { + // No singleton guarantees from the factory -> let's lock locally. return this; } } diff --git a/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/BeanFactoryAspectJAdvisorsBuilder.java b/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/BeanFactoryAspectJAdvisorsBuilder.java index a318ea56bb49..21bc248e508a 100644 --- a/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/BeanFactoryAspectJAdvisorsBuilder.java +++ b/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/BeanFactoryAspectJAdvisorsBuilder.java @@ -85,6 +85,7 @@ public BeanFactoryAspectJAdvisorsBuilder(ListableBeanFactory beanFactory, Aspect * @return the list of {@link org.springframework.aop.Advisor} beans * @see #isEligibleBean */ + @SuppressWarnings("NullAway") public List buildAspectJAdvisors() { List aspectNames = this.aspectBeanNames; diff --git a/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/InstantiationModelAwarePointcutAdvisorImpl.java b/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/InstantiationModelAwarePointcutAdvisorImpl.java index 07df51fb3f55..db20f7608131 100644 --- a/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/InstantiationModelAwarePointcutAdvisorImpl.java +++ b/spring-aop/src/main/java/org/springframework/aop/aspectj/annotation/InstantiationModelAwarePointcutAdvisorImpl.java @@ -195,6 +195,7 @@ public int getDeclarationOrder() { } @Override + @SuppressWarnings("NullAway") public boolean isBeforeAdvice() { if (this.isBeforeAdvice == null) { determineAdviceType(); @@ -203,6 +204,7 @@ public boolean isBeforeAdvice() { } @Override + @SuppressWarnings("NullAway") public boolean isAfterAdvice() { if (this.isAfterAdvice == null) { determineAdviceType(); diff --git a/spring-aop/src/main/java/org/springframework/aop/aspectj/autoproxy/AspectJAwareAdvisorAutoProxyCreator.java b/spring-aop/src/main/java/org/springframework/aop/aspectj/autoproxy/AspectJAwareAdvisorAutoProxyCreator.java index 3c56c8722632..255bfe961ccb 100644 --- a/spring-aop/src/main/java/org/springframework/aop/aspectj/autoproxy/AspectJAwareAdvisorAutoProxyCreator.java +++ b/spring-aop/src/main/java/org/springframework/aop/aspectj/autoproxy/AspectJAwareAdvisorAutoProxyCreator.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,8 +28,11 @@ import org.springframework.aop.aspectj.AbstractAspectJAdvice; import org.springframework.aop.aspectj.AspectJPointcutAdvisor; import org.springframework.aop.aspectj.AspectJProxyUtils; +import org.springframework.aop.aspectj.ShadowMatchUtils; import org.springframework.aop.framework.autoproxy.AbstractAdvisorAutoProxyCreator; import org.springframework.aop.interceptor.ExposeInvocationInterceptor; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.core.Ordered; import org.springframework.util.ClassUtils; @@ -44,7 +47,8 @@ * @since 2.0 */ @SuppressWarnings("serial") -public class AspectJAwareAdvisorAutoProxyCreator extends AbstractAdvisorAutoProxyCreator { +public class AspectJAwareAdvisorAutoProxyCreator extends AbstractAdvisorAutoProxyCreator + implements SmartInitializingSingleton, DisposableBean { private static final Comparator DEFAULT_PRECEDENCE_COMPARATOR = new AspectJPrecedenceComparator(); @@ -108,6 +112,16 @@ protected boolean shouldSkip(Class beanClass, String beanName) { return super.shouldSkip(beanClass, beanName); } + @Override + public void afterSingletonsInstantiated() { + ShadowMatchUtils.clearCache(); + } + + @Override + public void destroy() { + ShadowMatchUtils.clearCache(); + } + /** * Implements AspectJ's {@link PartialComparable} interface for defining partial orderings. diff --git a/spring-aop/src/main/java/org/springframework/aop/config/AbstractInterceptorDrivenBeanDefinitionDecorator.java b/spring-aop/src/main/java/org/springframework/aop/config/AbstractInterceptorDrivenBeanDefinitionDecorator.java index 28fc6cdbb69b..a97f79cbb11f 100644 --- a/spring-aop/src/main/java/org/springframework/aop/config/AbstractInterceptorDrivenBeanDefinitionDecorator.java +++ b/spring-aop/src/main/java/org/springframework/aop/config/AbstractInterceptorDrivenBeanDefinitionDecorator.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -89,7 +89,7 @@ public final BeanDefinitionHolder decorate(Node node, BeanDefinitionHolder defin proxyDefinition.setDecoratedDefinition(targetHolder); proxyDefinition.getPropertyValues().add("target", targetHolder); // create the interceptor names list - proxyDefinition.getPropertyValues().add("interceptorNames", new ManagedList()); + proxyDefinition.getPropertyValues().add("interceptorNames", new ManagedList<>()); // copy autowire settings from original bean definition. proxyDefinition.setAutowireCandidate(targetDefinition.isAutowireCandidate()); proxyDefinition.setPrimary(targetDefinition.isPrimary()); diff --git a/spring-aop/src/main/java/org/springframework/aop/config/ConfigBeanDefinitionParser.java b/spring-aop/src/main/java/org/springframework/aop/config/ConfigBeanDefinitionParser.java index f140b60c88db..c13c6446a383 100644 --- a/spring-aop/src/main/java/org/springframework/aop/config/ConfigBeanDefinitionParser.java +++ b/spring-aop/src/main/java/org/springframework/aop/config/ConfigBeanDefinitionParser.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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 1b021c7cd8d3..95eeda4a7a83 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 @@ -135,7 +135,7 @@ else if (advised.getTargetSource() == AdvisedSupport.EMPTY_TARGET_SOURCE && * Check whether the given bean is eligible for advising with this * post-processor's {@link Advisor}. *

Delegates to {@link #isEligible(Class)} for target class checking. - * Can be overridden e.g. to specifically exclude certain beans by name. + * Can be overridden, for example, to specifically exclude certain beans by name. *

Note: Only called for regular bean instances but not for existing * proxy instances which implement {@link Advised} and allow for adding * the local {@link Advisor} to the existing proxy's {@link Advisor} chain. diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/AbstractSingletonProxyFactoryBean.java b/spring-aop/src/main/java/org/springframework/aop/framework/AbstractSingletonProxyFactoryBean.java index cf40782c784a..fa1642835c0e 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/AbstractSingletonProxyFactoryBean.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/AbstractSingletonProxyFactoryBean.java @@ -91,7 +91,7 @@ public void setProxyInterfaces(Class[] proxyInterfaces) { /** * Set additional interceptors (or advisors) to be applied before the - * implicit transaction interceptor, e.g. a PerformanceMonitorInterceptor. + * implicit transaction interceptor, for example, a PerformanceMonitorInterceptor. *

You may specify any AOP Alliance MethodInterceptors or other * Spring AOP Advices, as well as Spring AOP Advisors. * @see org.springframework.aop.interceptor.PerformanceMonitorInterceptor diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/AdvisedSupport.java b/spring-aop/src/main/java/org/springframework/aop/framework/AdvisedSupport.java index b59960864aac..707c98f26760 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/AdvisedSupport.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/AdvisedSupport.java @@ -34,6 +34,7 @@ import org.springframework.aop.IntroductionInfo; import org.springframework.aop.Pointcut; import org.springframework.aop.PointcutAdvisor; +import org.springframework.aop.SpringProxy; import org.springframework.aop.TargetSource; import org.springframework.aop.support.DefaultIntroductionAdvisor; import org.springframework.aop.support.DefaultPointcutAdvisor; @@ -261,6 +262,28 @@ public boolean isInterfaceProxied(Class ifc) { return false; } + boolean hasUserSuppliedInterfaces() { + for (Class ifc : this.interfaces) { + if (!SpringProxy.class.isAssignableFrom(ifc) && !isAdvisorIntroducedInterface(ifc)) { + return true; + } + } + return false; + } + + private boolean isAdvisorIntroducedInterface(Class ifc) { + for (Advisor advisor : this.advisors) { + if (advisor instanceof IntroductionAdvisor introductionAdvisor) { + for (Class introducedInterface : introductionAdvisor.getInterfaces()) { + if (introducedInterface == ifc) { + return true; + } + } + } + } + return false; + } + @Override public final Advisor[] getAdvisors() { @@ -645,7 +668,8 @@ public MethodCacheKey(Method method) { @Override public boolean equals(@Nullable Object other) { - return (this == other || (other instanceof MethodCacheKey that && this.method == that.method)); + return (this == other || (other instanceof MethodCacheKey that && + (this.method == that.method || this.method.equals(that.method)))); } @Override diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/CglibAopProxy.java b/spring-aop/src/main/java/org/springframework/aop/framework/CglibAopProxy.java index 44bdf08db7ba..aad0b4e9e0db 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/CglibAopProxy.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/CglibAopProxy.java @@ -37,6 +37,7 @@ import org.springframework.aop.support.AopUtils; import org.springframework.cglib.core.ClassLoaderAwareGeneratorStrategy; import org.springframework.cglib.core.CodeGenerationException; +import org.springframework.cglib.core.GeneratorStrategy; import org.springframework.cglib.core.SpringNamingPolicy; import org.springframework.cglib.proxy.Callback; import org.springframework.cglib.proxy.CallbackFilter; @@ -46,6 +47,7 @@ import org.springframework.cglib.proxy.MethodInterceptor; import org.springframework.cglib.proxy.MethodProxy; import org.springframework.cglib.proxy.NoOp; +import org.springframework.cglib.transform.impl.UndeclaredThrowableStrategy; import org.springframework.core.KotlinDetector; import org.springframework.core.MethodParameter; import org.springframework.core.SmartClassLoader; @@ -54,7 +56,6 @@ import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; -import org.springframework.util.ReflectionUtils; /** * CGLIB-based {@link AopProxy} implementation for the Spring AOP framework. @@ -99,6 +100,9 @@ class CglibAopProxy implements AopProxy, Serializable { private static final boolean coroutinesReactorPresent = ClassUtils.isPresent( "kotlinx.coroutines.reactor.MonoKt", CglibAopProxy.class.getClassLoader()); + private static final GeneratorStrategy undeclaredThrowableStrategy = + new UndeclaredThrowableStrategy(UndeclaredThrowableException.class); + /** Logger available to subclasses; static to optimize serialization. */ protected static final Log logger = LogFactory.getLog(CglibAopProxy.class); @@ -202,7 +206,10 @@ private Object buildProxy(@Nullable ClassLoader classLoader, boolean classOnly) enhancer.setInterfaces(AopProxyUtils.completeProxiedInterfaces(this.advised)); enhancer.setNamingPolicy(SpringNamingPolicy.INSTANCE); enhancer.setAttemptLoad(true); - enhancer.setStrategy(new ClassLoaderAwareGeneratorStrategy(classLoader)); + enhancer.setStrategy(KotlinDetector.isKotlinType(proxySuperClass) ? + new ClassLoaderAwareGeneratorStrategy(classLoader) : + new ClassLoaderAwareGeneratorStrategy(classLoader, undeclaredThrowableStrategy) + ); Callback[] callbacks = getCallbacks(rootClass); Class[] types = new Class[callbacks.length]; @@ -665,8 +672,8 @@ public FixedChainStaticTargetInterceptor( @Override @Nullable public Object intercept(Object proxy, Method method, Object[] args, MethodProxy methodProxy) throws Throwable { - MethodInvocation invocation = new CglibMethodInvocation( - proxy, this.target, method, args, this.targetClass, this.adviceChain, methodProxy); + MethodInvocation invocation = new ReflectiveMethodInvocation( + proxy, this.target, method, args, this.targetClass, this.adviceChain); // If we get here, we need to create a MethodInvocation. Object retVal = invocation.proceed(); retVal = processReturnType(proxy, this.target, method, args, retVal); @@ -717,7 +724,7 @@ public Object intercept(Object proxy, Method method, Object[] args, MethodProxy } else { // We need to create a method invocation... - retVal = new CglibMethodInvocation(proxy, target, method, args, targetClass, chain, methodProxy).proceed(); + retVal = new ReflectiveMethodInvocation(proxy, target, method, args, targetClass, chain).proceed(); } return processReturnType(proxy, target, method, args, retVal); } @@ -749,46 +756,6 @@ public int hashCode() { } - /** - * Implementation of AOP Alliance MethodInvocation used by this AOP proxy. - */ - private static class CglibMethodInvocation extends ReflectiveMethodInvocation { - - public CglibMethodInvocation(Object proxy, @Nullable Object target, Method method, - Object[] arguments, @Nullable Class targetClass, - List interceptorsAndDynamicMethodMatchers, MethodProxy methodProxy) { - - super(proxy, target, method, arguments, targetClass, interceptorsAndDynamicMethodMatchers); - } - - @Override - @Nullable - public Object proceed() throws Throwable { - try { - return super.proceed(); - } - catch (RuntimeException ex) { - throw ex; - } - catch (Exception ex) { - if (ReflectionUtils.declaresException(getMethod(), ex.getClass()) || - KotlinDetector.isKotlinType(getMethod().getDeclaringClass())) { - // Propagate original exception if declared on the target method - // (with callers expecting it). Always propagate it for Kotlin code - // since checked exceptions do not have to be explicitly declared there. - throw ex; - } - else { - // Checked exception thrown in the interceptor but not declared on the - // target method signature -> apply an UndeclaredThrowableException, - // aligned with standard JDK dynamic proxy behavior. - throw new UndeclaredThrowableException(ex); - } - } - } - } - - /** * CallbackFilter to assign Callbacks to methods. */ diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/DefaultAopProxyFactory.java b/spring-aop/src/main/java/org/springframework/aop/framework/DefaultAopProxyFactory.java index f97455dfc45c..232e3bf13c46 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/DefaultAopProxyFactory.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/DefaultAopProxyFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +19,6 @@ import java.io.Serializable; import java.lang.reflect.Proxy; -import org.springframework.aop.SpringProxy; import org.springframework.util.ClassUtils; /** @@ -59,13 +58,14 @@ public class DefaultAopProxyFactory implements AopProxyFactory, Serializable { @Override public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException { - if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) { + if (config.isOptimize() || config.isProxyTargetClass() || !config.hasUserSuppliedInterfaces()) { Class targetClass = config.getTargetClass(); - if (targetClass == null) { + if (targetClass == null && config.getProxiedInterfaces().length == 0) { throw new AopConfigException("TargetSource cannot determine target class: " + "Either an interface or a target is required for proxy creation."); } - if (targetClass.isInterface() || Proxy.isProxyClass(targetClass) || ClassUtils.isLambdaClass(targetClass)) { + if (targetClass == null || targetClass.isInterface() || + Proxy.isProxyClass(targetClass) || ClassUtils.isLambdaClass(targetClass)) { return new JdkDynamicAopProxy(config); } return new ObjenesisCglibAopProxy(config); @@ -75,14 +75,4 @@ public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException } } - /** - * Determine whether the supplied {@link AdvisedSupport} has only the - * {@link org.springframework.aop.SpringProxy} interface specified - * (or no proxy interfaces specified at all). - */ - private boolean hasNoUserSuppliedProxyInterfaces(AdvisedSupport config) { - Class[] ifcs = config.getProxiedInterfaces(); - return (ifcs.length == 0 || (ifcs.length == 1 && SpringProxy.class.isAssignableFrom(ifcs[0]))); - } - } diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/ReflectiveMethodInvocation.java b/spring-aop/src/main/java/org/springframework/aop/framework/ReflectiveMethodInvocation.java index cc29883d590a..b5ad8c36f1e0 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/ReflectiveMethodInvocation.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/ReflectiveMethodInvocation.java @@ -47,7 +47,7 @@ * *

NOTE: This class is considered internal and should not be * directly accessed. The sole reason for it being public is compatibility - * with existing framework integrations (e.g. Pitchfork). For any other + * with existing framework integrations (for example, Pitchfork). For any other * purposes, use the {@link ProxyMethodInvocation} interface instead. * * @author Rod Johnson 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 32d4c7e6bdb8..ffdf6173f126 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-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +20,6 @@ import java.lang.reflect.Proxy; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; @@ -72,7 +71,7 @@ * Instead of x repetitive proxy definitions for x target beans, you can register * one single such post processor with the bean factory to achieve the same effect. * - *

Subclasses can apply any strategy to decide if a bean is to be proxied, e.g. by type, + *

Subclasses can apply any strategy to decide if a bean is to be proxied, for example, by type, * by name, by definition details, etc. They can also return additional interceptors that * should just be applied to the specific bean instance. A simple concrete implementation is * {@link BeanNameAutoProxyCreator}, identifying the beans to be proxied via given names. @@ -136,7 +135,7 @@ public abstract class AbstractAutoProxyCreator extends ProxyProcessorSupport @Nullable private BeanFactory beanFactory; - private final Set targetSourcedBeans = Collections.newSetFromMap(new ConcurrentHashMap<>(16)); + private final Set targetSourcedBeans = ConcurrentHashMap.newKeySet(16); private final Map earlyBeanReferences = new ConcurrentHashMap<>(16); @@ -404,7 +403,7 @@ protected boolean isInfrastructureClass(Class beanClass) { /** * Subclasses should override this method to return {@code true} if the * given bean should not be considered for auto-proxying by this post-processor. - *

Sometimes we need to be able to avoid this happening, e.g. if it will lead to + *

Sometimes we need to be able to avoid this happening, for example, if it will lead to * a circular reference or if the existing target instance needs to be preserved. * This implementation returns {@code false} unless the bean name indicates an * "original instance" according to {@code AutowireCapableBeanFactory} conventions. @@ -620,7 +619,7 @@ protected void customizeProxyFactory(ProxyFactory proxyFactory) { /** * Return whether the given bean is to be proxied, what additional - * advices (e.g. AOP Alliance interceptors) and advisors to apply. + * advices (for example, AOP Alliance interceptors) and advisors to apply. * @param beanClass the class of the bean to advise * @param beanName the name of the bean * @param customTargetSource the TargetSource returned by the diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AutoProxyUtils.java b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AutoProxyUtils.java index 1de9382a2e2c..92c25b432d5d 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AutoProxyUtils.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AutoProxyUtils.java @@ -47,7 +47,7 @@ public abstract class AutoProxyUtils { /** * Bean definition attribute that indicates the original target class of an - * auto-proxied bean, e.g. to be used for the introspection of annotations + * auto-proxied bean, for example, to be used for the introspection of annotations * on the target class behind an interface-based proxy. * @since 4.2.3 * @see #determineTargetClass diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/BeanNameAutoProxyCreator.java b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/BeanNameAutoProxyCreator.java index c9ea561366a4..27aa0547363c 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/BeanNameAutoProxyCreator.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/BeanNameAutoProxyCreator.java @@ -54,13 +54,13 @@ public class BeanNameAutoProxyCreator extends AbstractAutoProxyCreator { /** * Set the names of the beans that should automatically get wrapped with proxies. - * A name can specify a prefix to match by ending with "*", e.g. "myBean,tx*" + * A name can specify a prefix to match by ending with "*", for example, "myBean,tx*" * will match the bean named "myBean" and all beans whose name start with "tx". *

NOTE: In case of a FactoryBean, only the objects created by the * FactoryBean will get proxied. This default behavior applies as of Spring 2.0. * If you intend to proxy a FactoryBean instance itself (a rare use case, but * Spring 1.2's default behavior), specify the bean name of the FactoryBean - * including the factory-bean prefix "&": e.g. "&myFactoryBean". + * including the factory-bean prefix "&": for example, "&myFactoryBean". * @see org.springframework.beans.factory.FactoryBean * @see org.springframework.beans.factory.BeanFactory#FACTORY_BEAN_PREFIX */ diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/target/AbstractBeanFactoryBasedTargetSourceCreator.java b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/target/AbstractBeanFactoryBasedTargetSourceCreator.java index 650b40736cd6..b9a5e4e4c422 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/target/AbstractBeanFactoryBasedTargetSourceCreator.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/target/AbstractBeanFactoryBasedTargetSourceCreator.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -151,8 +151,7 @@ protected DefaultListableBeanFactory buildInternalBeanFactory(ConfigurableBeanFa // Filter out BeanPostProcessors that are part of the AOP infrastructure, // since those are only meant to apply to beans defined in the original factory. - internalBeanFactory.getBeanPostProcessors().removeIf(beanPostProcessor -> - beanPostProcessor instanceof AopInfrastructureBean); + internalBeanFactory.getBeanPostProcessors().removeIf(AopInfrastructureBean.class::isInstance); return internalBeanFactory; } diff --git a/spring-aop/src/main/java/org/springframework/aop/interceptor/AsyncExecutionAspectSupport.java b/spring-aop/src/main/java/org/springframework/aop/interceptor/AsyncExecutionAspectSupport.java index a9b63d90091e..ea5034799622 100644 --- a/spring-aop/src/main/java/org/springframework/aop/interceptor/AsyncExecutionAspectSupport.java +++ b/spring-aop/src/main/java/org/springframework/aop/interceptor/AsyncExecutionAspectSupport.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,7 +52,7 @@ *

Provides support for executor qualification on a method-by-method basis. * {@code AsyncExecutionAspectSupport} objects must be constructed with a default {@code * Executor}, but each individual method may further qualify a specific {@code Executor} - * bean to be used when executing it, e.g. through an annotation attribute. + * bean to be used when executing it, for example, through an annotation attribute. * * @author Chris Beams * @author Juergen Hoeller @@ -281,8 +281,8 @@ protected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) { * @param returnType the declared return type (potentially a {@link Future} variant) * @return the execution result (potentially a corresponding {@link Future} handle) */ + @SuppressWarnings("removal") @Nullable - @SuppressWarnings("deprecation") protected Object doSubmit(Callable task, AsyncTaskExecutor executor, Class returnType) { if (CompletableFuture.class.isAssignableFrom(returnType)) { return executor.submitCompletable(task); diff --git a/spring-aop/src/main/java/org/springframework/aop/interceptor/AsyncExecutionInterceptor.java b/spring-aop/src/main/java/org/springframework/aop/interceptor/AsyncExecutionInterceptor.java index c2ea5aad5a4f..12e073db035f 100644 --- a/spring-aop/src/main/java/org/springframework/aop/interceptor/AsyncExecutionInterceptor.java +++ b/spring-aop/src/main/java/org/springframework/aop/interceptor/AsyncExecutionInterceptor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,6 +98,7 @@ public AsyncExecutionInterceptor(@Nullable Executor defaultExecutor, AsyncUncaug */ @Override @Nullable + @SuppressWarnings("NullAway") public Object invoke(final MethodInvocation invocation) throws Throwable { Class targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null); final Method userMethod = BridgeMethodResolver.getMostSpecificMethod(invocation.getMethod(), targetClass); @@ -124,7 +125,7 @@ public Object invoke(final MethodInvocation invocation) throws Throwable { return null; }; - return doSubmit(task, executor, invocation.getMethod().getReturnType()); + return doSubmit(task, executor, userMethod.getReturnType()); } /** @@ -147,7 +148,7 @@ protected String getExecutorQualifier(Method method) { /** * This implementation searches for a unique {@link org.springframework.core.task.TaskExecutor} * bean in the context, or for an {@link Executor} bean named "taskExecutor" otherwise. - * If neither of the two is resolvable (e.g. if no {@code BeanFactory} was configured at all), + * If neither of the two is resolvable (for example, if no {@code BeanFactory} was configured at all), * this implementation falls back to a newly created {@link SimpleAsyncTaskExecutor} instance * for local use if no default could be found. * @see #DEFAULT_TASK_EXECUTOR_BEAN_NAME diff --git a/spring-aop/src/main/java/org/springframework/aop/interceptor/ConcurrencyThrottleInterceptor.java b/spring-aop/src/main/java/org/springframework/aop/interceptor/ConcurrencyThrottleInterceptor.java index dd802ce813bd..3e6e6e9b7c5b 100644 --- a/spring-aop/src/main/java/org/springframework/aop/interceptor/ConcurrencyThrottleInterceptor.java +++ b/spring-aop/src/main/java/org/springframework/aop/interceptor/ConcurrencyThrottleInterceptor.java @@ -31,7 +31,7 @@ *

Can be applied to methods of local services that involve heavy use * of system resources, in a scenario where it is more efficient to * throttle concurrency for a specific service rather than restricting - * the entire thread pool (e.g. the web container's thread pool). + * the entire thread pool (for example, the web container's thread pool). * *

The default concurrency limit of this interceptor is 1. * Specify the "concurrencyLimit" bean property to change this value. diff --git a/spring-aop/src/main/java/org/springframework/aop/interceptor/ExposeInvocationInterceptor.java b/spring-aop/src/main/java/org/springframework/aop/interceptor/ExposeInvocationInterceptor.java index 9822374da1a3..18c9caf1ea87 100644 --- a/spring-aop/src/main/java/org/springframework/aop/interceptor/ExposeInvocationInterceptor.java +++ b/spring-aop/src/main/java/org/springframework/aop/interceptor/ExposeInvocationInterceptor.java @@ -30,7 +30,7 @@ /** * Interceptor that exposes the current {@link org.aopalliance.intercept.MethodInvocation} * as a thread-local object. We occasionally need to do this; for example, when a pointcut - * (e.g. an AspectJ expression pointcut) needs to know the full invocation context. + * (for example, an AspectJ expression pointcut) needs to know the full invocation context. * *

Don't use this interceptor unless this is really necessary. Target objects should * not normally know about Spring AOP, as this creates a dependency on Spring API. diff --git a/spring-aop/src/main/java/org/springframework/aop/scope/ScopedProxyBeanRegistrationAotProcessor.java b/spring-aop/src/main/java/org/springframework/aop/scope/ScopedProxyBeanRegistrationAotProcessor.java index 830ca6e7f5b8..af2c7af67aff 100644 --- a/spring-aop/src/main/java/org/springframework/aop/scope/ScopedProxyBeanRegistrationAotProcessor.java +++ b/spring-aop/src/main/java/org/springframework/aop/scope/ScopedProxyBeanRegistrationAotProcessor.java @@ -54,6 +54,7 @@ class ScopedProxyBeanRegistrationAotProcessor implements BeanRegistrationAotProc @Override @Nullable + @SuppressWarnings("NullAway") public BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) { Class beanClass = registeredBean.getBeanClass(); if (beanClass.equals(ScopedProxyFactoryBean.class)) { diff --git a/spring-aop/src/main/java/org/springframework/aop/scope/ScopedProxyUtils.java b/spring-aop/src/main/java/org/springframework/aop/scope/ScopedProxyUtils.java index 968cea81833d..2eee3a42581e 100644 --- a/spring-aop/src/main/java/org/springframework/aop/scope/ScopedProxyUtils.java +++ b/spring-aop/src/main/java/org/springframework/aop/scope/ScopedProxyUtils.java @@ -22,6 +22,7 @@ import org.springframework.beans.factory.support.AbstractBeanDefinition; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.lang.Contract; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -128,6 +129,7 @@ public static String getOriginalBeanName(@Nullable String targetBeanName) { * the target bean within a scoped proxy. * @since 4.1.4 */ + @Contract("null -> false") public static boolean isScopedTarget(@Nullable String beanName) { return (beanName != null && beanName.startsWith(TARGET_NAME_PREFIX)); } diff --git a/spring-aop/src/main/java/org/springframework/aop/support/AbstractBeanFactoryPointcutAdvisor.java b/spring-aop/src/main/java/org/springframework/aop/support/AbstractBeanFactoryPointcutAdvisor.java index f9efdc469ada..e6a10c621bf1 100644 --- a/spring-aop/src/main/java/org/springframework/aop/support/AbstractBeanFactoryPointcutAdvisor.java +++ b/spring-aop/src/main/java/org/springframework/aop/support/AbstractBeanFactoryPointcutAdvisor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,6 @@ import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; -import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -52,7 +51,7 @@ public abstract class AbstractBeanFactoryPointcutAdvisor extends AbstractPointcu @Nullable private transient volatile Advice advice; - private transient volatile Object adviceMonitor = new Object(); + private transient Object adviceMonitor = new Object(); /** @@ -78,16 +77,6 @@ public String getAdviceBeanName() { @Override public void setBeanFactory(BeanFactory beanFactory) { this.beanFactory = beanFactory; - resetAdviceMonitor(); - } - - private void resetAdviceMonitor() { - if (this.beanFactory instanceof ConfigurableBeanFactory cbf) { - this.adviceMonitor = cbf.getSingletonMutex(); - } - else { - this.adviceMonitor = new Object(); - } } /** @@ -118,9 +107,7 @@ public Advice getAdvice() { return advice; } else { - // No singleton guarantees from the factory -> let's lock locally but - // reuse the factory's singleton lock, just in case a lazy dependency - // of our advice bean happens to trigger the singleton lock implicitly... + // No singleton guarantees from the factory -> let's lock locally. synchronized (this.adviceMonitor) { advice = this.advice; if (advice == null) { @@ -155,7 +142,7 @@ private void readObject(ObjectInputStream ois) throws IOException, ClassNotFound ois.defaultReadObject(); // Initialize transient fields. - resetAdviceMonitor(); + this.adviceMonitor = new Object(); } } diff --git a/spring-aop/src/main/java/org/springframework/aop/support/AbstractRegexpMethodPointcut.java b/spring-aop/src/main/java/org/springframework/aop/support/AbstractRegexpMethodPointcut.java index 30fead6732fe..acb56fd468d2 100644 --- a/spring-aop/src/main/java/org/springframework/aop/support/AbstractRegexpMethodPointcut.java +++ b/spring-aop/src/main/java/org/springframework/aop/support/AbstractRegexpMethodPointcut.java @@ -29,7 +29,7 @@ * Abstract base regular expression pointcut bean. JavaBean properties are: *

    *
  • pattern: regular expression for the fully-qualified method names to match. - * The exact regexp syntax will depend on the subclass (e.g. Perl5 regular expressions) + * The exact regexp syntax will depend on the subclass (for example, Perl5 regular expressions) *
  • patterns: alternative property taking a String array of patterns. * The result will be the union of these patterns. *
diff --git a/spring-aop/src/main/java/org/springframework/aop/support/AopUtils.java b/spring-aop/src/main/java/org/springframework/aop/support/AopUtils.java index be3af49edb6a..38eba72e6d7a 100644 --- a/spring-aop/src/main/java/org/springframework/aop/support/AopUtils.java +++ b/spring-aop/src/main/java/org/springframework/aop/support/AopUtils.java @@ -42,6 +42,7 @@ import org.springframework.core.CoroutinesUtils; import org.springframework.core.KotlinDetector; import org.springframework.core.MethodIntrospector; +import org.springframework.lang.Contract; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -76,6 +77,7 @@ public abstract class AopUtils { * @see #isJdkDynamicProxy * @see #isCglibProxy */ + @Contract("null -> false") public static boolean isAopProxy(@Nullable Object object) { return (object instanceof SpringProxy && (Proxy.isProxyClass(object.getClass()) || object.getClass().getName().contains(ClassUtils.CGLIB_CLASS_SEPARATOR))); @@ -89,6 +91,7 @@ public static boolean isAopProxy(@Nullable Object object) { * @param object the object to check * @see java.lang.reflect.Proxy#isProxyClass */ + @Contract("null -> false") public static boolean isJdkDynamicProxy(@Nullable Object object) { return (object instanceof SpringProxy && Proxy.isProxyClass(object.getClass())); } @@ -101,6 +104,7 @@ public static boolean isJdkDynamicProxy(@Nullable Object object) { * @param object the object to check * @see ClassUtils#isCglibProxy(Object) */ + @Contract("null -> false") public static boolean isCglibProxy(@Nullable Object object) { return (object instanceof SpringProxy && object.getClass().getName().contains(ClassUtils.CGLIB_CLASS_SEPARATOR)); @@ -190,7 +194,7 @@ public static boolean isFinalizeMethod(@Nullable Method method) { /** * Given a method, which may come from an interface, and a target class used * in the current AOP invocation, find the corresponding target method if there - * is one. E.g. the method may be {@code IFoo.bar()} and the target class + * is one. For example, the method may be {@code IFoo.bar()} and the target class * may be {@code DefaultFoo}. In this case, the method may be * {@code DefaultFoo.bar()}. This enables attributes on that method to be found. *

NOTE: In contrast to {@link org.springframework.util.ClassUtils#getMostSpecificMethod}, diff --git a/spring-aop/src/main/java/org/springframework/aop/support/annotation/AnnotationMatchingPointcut.java b/spring-aop/src/main/java/org/springframework/aop/support/annotation/AnnotationMatchingPointcut.java index 421063c67fd0..c6e11ecf77ae 100644 --- a/spring-aop/src/main/java/org/springframework/aop/support/annotation/AnnotationMatchingPointcut.java +++ b/spring-aop/src/main/java/org/springframework/aop/support/annotation/AnnotationMatchingPointcut.java @@ -87,6 +87,7 @@ public AnnotationMatchingPointcut(@Nullable Class classAnn * @see AnnotationClassFilter#AnnotationClassFilter(Class, boolean) * @see AnnotationMethodMatcher#AnnotationMethodMatcher(Class, boolean) */ + @SuppressWarnings("NullAway") public AnnotationMatchingPointcut(@Nullable Class classAnnotationType, @Nullable Class methodAnnotationType, boolean checkInherited) { diff --git a/spring-aop/src/main/java/org/springframework/aop/target/dynamic/AbstractRefreshableTargetSource.java b/spring-aop/src/main/java/org/springframework/aop/target/dynamic/AbstractRefreshableTargetSource.java index e6f386bf55e8..5871ac8b95d1 100644 --- a/spring-aop/src/main/java/org/springframework/aop/target/dynamic/AbstractRefreshableTargetSource.java +++ b/spring-aop/src/main/java/org/springframework/aop/target/dynamic/AbstractRefreshableTargetSource.java @@ -66,6 +66,7 @@ public void setRefreshCheckDelay(long refreshCheckDelay) { @Override + @SuppressWarnings("NullAway") public synchronized Class getTargetClass() { if (this.targetObject == null) { refresh(); diff --git a/spring-aop/src/test/java/org/springframework/aop/aspectj/AbstractAspectJAdviceTests.java b/spring-aop/src/test/java/org/springframework/aop/aspectj/AbstractAspectJAdviceTests.java new file mode 100644 index 000000000000..f6768b0e8440 --- /dev/null +++ b/spring-aop/src/test/java/org/springframework/aop/aspectj/AbstractAspectJAdviceTests.java @@ -0,0 +1,135 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.aop.aspectj; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.function.Consumer; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link AbstractAspectJAdvice}. + * + * @author Joshua Chen + * @author Stephane Nicoll + */ +class AbstractAspectJAdviceTests { + + @Test + void setArgumentNamesFromStringArray_withoutJoinPointParameter() { + AbstractAspectJAdvice advice = getAspectJAdvice("methodWithNoJoinPoint"); + assertThat(advice).satisfies(hasArgumentNames("arg1", "arg2")); + } + + @Test + void setArgumentNamesFromStringArray_withJoinPointAsFirstParameter() { + AbstractAspectJAdvice advice = getAspectJAdvice("methodWithJoinPointAsFirstParameter"); + assertThat(advice).satisfies(hasArgumentNames("THIS_JOIN_POINT", "arg1", "arg2")); + } + + @Test + void setArgumentNamesFromStringArray_withJoinPointAsLastParameter() { + AbstractAspectJAdvice advice = getAspectJAdvice("methodWithJoinPointAsLastParameter"); + assertThat(advice).satisfies(hasArgumentNames("arg1", "arg2", "THIS_JOIN_POINT")); + } + + @Test + void setArgumentNamesFromStringArray_withJoinPointAsMiddleParameter() { + AbstractAspectJAdvice advice = getAspectJAdvice("methodWithJoinPointAsMiddleParameter"); + assertThat(advice).satisfies(hasArgumentNames("arg1", "THIS_JOIN_POINT", "arg2")); + } + + @Test + void setArgumentNamesFromStringArray_withProceedingJoinPoint() { + AbstractAspectJAdvice advice = getAspectJAdvice("methodWithProceedingJoinPoint"); + assertThat(advice).satisfies(hasArgumentNames("THIS_JOIN_POINT", "arg1", "arg2")); + } + + @Test + void setArgumentNamesFromStringArray_withStaticPart() { + AbstractAspectJAdvice advice = getAspectJAdvice("methodWithStaticPart"); + assertThat(advice).satisfies(hasArgumentNames("THIS_JOIN_POINT", "arg1", "arg2")); + } + + private Consumer hasArgumentNames(String... argumentNames) { + return advice -> assertThat(advice).extracting("argumentNames") + .asInstanceOf(InstanceOfAssertFactories.array(String[].class)) + .containsExactly(argumentNames); + } + + private AbstractAspectJAdvice getAspectJAdvice(final String methodName) { + AbstractAspectJAdvice advice = new TestAspectJAdvice(getMethod(methodName), + mock(AspectJExpressionPointcut.class), mock(AspectInstanceFactory.class)); + advice.setArgumentNamesFromStringArray("arg1", "arg2"); + return advice; + } + + private Method getMethod(final String methodName) { + return Arrays.stream(Sample.class.getDeclaredMethods()) + .filter(method -> method.getName().equals(methodName)).findFirst() + .orElseThrow(); + } + + @SuppressWarnings("serial") + public static class TestAspectJAdvice extends AbstractAspectJAdvice { + + public TestAspectJAdvice(Method aspectJAdviceMethod, AspectJExpressionPointcut pointcut, + AspectInstanceFactory aspectInstanceFactory) { + super(aspectJAdviceMethod, pointcut, aspectInstanceFactory); + } + + @Override + public boolean isBeforeAdvice() { + return false; + } + + @Override + public boolean isAfterAdvice() { + return false; + } + } + + @SuppressWarnings("unused") + static class Sample { + + void methodWithNoJoinPoint(String arg1, String arg2) { + } + + void methodWithJoinPointAsFirstParameter(JoinPoint joinPoint, String arg1, String arg2) { + } + + void methodWithJoinPointAsLastParameter(String arg1, String arg2, JoinPoint joinPoint) { + } + + void methodWithJoinPointAsMiddleParameter(String arg1, JoinPoint joinPoint, String arg2) { + } + + void methodWithProceedingJoinPoint(ProceedingJoinPoint joinPoint, String arg1, String arg2) { + } + + void methodWithStaticPart(JoinPoint.StaticPart staticPart, String arg1, String arg2) { + } + } + +} diff --git a/spring-aop/src/test/java/org/springframework/aop/aspectj/annotation/AspectProxyFactoryTests.java b/spring-aop/src/test/java/org/springframework/aop/aspectj/annotation/AspectProxyFactoryTests.java index 1b3557eae2dd..9e45538c713f 100644 --- a/spring-aop/src/test/java/org/springframework/aop/aspectj/annotation/AspectProxyFactoryTests.java +++ b/spring-aop/src/test/java/org/springframework/aop/aspectj/annotation/AspectProxyFactoryTests.java @@ -41,8 +41,7 @@ class AspectProxyFactoryTests { @Test void testWithNonAspect() { AspectJProxyFactory proxyFactory = new AspectJProxyFactory(new TestBean()); - assertThatIllegalArgumentException().isThrownBy(() -> - proxyFactory.addAspect(TestBean.class)); + assertThatIllegalArgumentException().isThrownBy(() -> proxyFactory.addAspect(TestBean.class)); } @Test @@ -78,8 +77,7 @@ void testWithPerThisAspect() { @Test void testWithInstanceWithNonAspect() { AspectJProxyFactory pf = new AspectJProxyFactory(); - assertThatIllegalArgumentException().isThrownBy(() -> - pf.addAspect(new TestBean())); + assertThatIllegalArgumentException().isThrownBy(() -> pf.addAspect(new TestBean())); } @Test @@ -119,6 +117,7 @@ void testWithNonSingletonAspectInstance() { } @Test // SPR-13328 + @SuppressWarnings("unchecked") public void testProxiedVarargsWithEnumArray() { AspectJProxyFactory proxyFactory = new AspectJProxyFactory(new TestBean()); proxyFactory.addAspect(LoggingAspectOnVarargs.class); @@ -127,6 +126,7 @@ public void testProxiedVarargsWithEnumArray() { } @Test // SPR-13328 + @SuppressWarnings("unchecked") public void testUnproxiedVarargsWithEnumArray() { AspectJProxyFactory proxyFactory = new AspectJProxyFactory(new TestBean()); proxyFactory.addAspect(LoggingAspectOnSetter.class); diff --git a/spring-aop/src/test/java/org/springframework/aop/framework/AbstractProxyExceptionHandlingTests.java b/spring-aop/src/test/java/org/springframework/aop/framework/AbstractProxyExceptionHandlingTests.java new file mode 100644 index 000000000000..6246d32ec36e --- /dev/null +++ b/spring-aop/src/test/java/org/springframework/aop/framework/AbstractProxyExceptionHandlingTests.java @@ -0,0 +1,190 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.aop.framework; + +import java.lang.reflect.UndeclaredThrowableException; +import java.util.Objects; + +import org.aopalliance.intercept.MethodInterceptor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; + +import org.springframework.lang.Nullable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.BDDMockito.willAnswer; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; + +/** + * @author Mikaël Francoeur + * @author Sam Brannen + * @since 6.2 + * @see JdkProxyExceptionHandlingTests + * @see CglibProxyExceptionHandlingTests + */ +abstract class AbstractProxyExceptionHandlingTests { + + private static final RuntimeException uncheckedException = new RuntimeException(); + + private static final DeclaredCheckedException declaredCheckedException = new DeclaredCheckedException(); + + private static final UndeclaredCheckedException undeclaredCheckedException = new UndeclaredCheckedException(); + + protected final MyClass target = mock(); + + protected final ProxyFactory proxyFactory = new ProxyFactory(target); + + protected MyInterface proxy; + + private Throwable throwableSeenByCaller; + + + @BeforeEach + void clear() { + Mockito.clearInvocations(target); + } + + + protected abstract void assertProxyType(Object proxy); + + + private void invokeProxy() { + throwableSeenByCaller = catchThrowable(() -> Objects.requireNonNull(proxy).doSomething()); + } + + @SuppressWarnings("SameParameterValue") + private static Answer sneakyThrow(Throwable throwable) { + return invocation -> { + throw throwable; + }; + } + + + @Nested + class WhenThereIsOneInterceptorTests { + + @Nullable + private Throwable throwableSeenByInterceptor; + + @BeforeEach + void beforeEach() { + proxyFactory.addAdvice(captureThrowable()); + proxy = (MyInterface) proxyFactory.getProxy(getClass().getClassLoader()); + assertProxyType(proxy); + } + + @Test + void targetThrowsUndeclaredCheckedException() throws DeclaredCheckedException { + willAnswer(sneakyThrow(undeclaredCheckedException)).given(target).doSomething(); + invokeProxy(); + assertThat(throwableSeenByInterceptor).isSameAs(undeclaredCheckedException); + assertThat(throwableSeenByCaller) + .isInstanceOf(UndeclaredThrowableException.class) + .cause().isSameAs(undeclaredCheckedException); + } + + @Test + void targetThrowsDeclaredCheckedException() throws DeclaredCheckedException { + willThrow(declaredCheckedException).given(target).doSomething(); + invokeProxy(); + assertThat(throwableSeenByInterceptor).isSameAs(declaredCheckedException); + assertThat(throwableSeenByCaller).isSameAs(declaredCheckedException); + } + + @Test + void targetThrowsUncheckedException() throws DeclaredCheckedException { + willThrow(uncheckedException).given(target).doSomething(); + invokeProxy(); + assertThat(throwableSeenByInterceptor).isSameAs(uncheckedException); + assertThat(throwableSeenByCaller).isSameAs(uncheckedException); + } + + private MethodInterceptor captureThrowable() { + return invocation -> { + try { + return invocation.proceed(); + } + catch (Exception ex) { + throwableSeenByInterceptor = ex; + throw ex; + } + }; + } + } + + + @Nested + class WhenThereAreNoInterceptorsTests { + + @BeforeEach + void beforeEach() { + proxy = (MyInterface) proxyFactory.getProxy(getClass().getClassLoader()); + assertProxyType(proxy); + } + + @Test + void targetThrowsUndeclaredCheckedException() throws DeclaredCheckedException { + willAnswer(sneakyThrow(undeclaredCheckedException)).given(target).doSomething(); + invokeProxy(); + assertThat(throwableSeenByCaller) + .isInstanceOf(UndeclaredThrowableException.class) + .cause().isSameAs(undeclaredCheckedException); + } + + @Test + void targetThrowsDeclaredCheckedException() throws DeclaredCheckedException { + willThrow(declaredCheckedException).given(target).doSomething(); + invokeProxy(); + assertThat(throwableSeenByCaller).isSameAs(declaredCheckedException); + } + + @Test + void targetThrowsUncheckedException() throws DeclaredCheckedException { + willThrow(uncheckedException).given(target).doSomething(); + invokeProxy(); + assertThat(throwableSeenByCaller).isSameAs(uncheckedException); + } + } + + + interface MyInterface { + + void doSomething() throws DeclaredCheckedException; + } + + static class MyClass implements MyInterface { + + @Override + public void doSomething() throws DeclaredCheckedException { + throw declaredCheckedException; + } + } + + @SuppressWarnings("serial") + private static class UndeclaredCheckedException extends Exception { + } + + @SuppressWarnings("serial") + private static class DeclaredCheckedException extends Exception { + } + +} diff --git a/spring-aop/src/test/java/org/springframework/aop/framework/CglibProxyExceptionHandlingTests.java b/spring-aop/src/test/java/org/springframework/aop/framework/CglibProxyExceptionHandlingTests.java new file mode 100644 index 000000000000..e99075967c3a --- /dev/null +++ b/spring-aop/src/test/java/org/springframework/aop/framework/CglibProxyExceptionHandlingTests.java @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.aop.framework; + +import org.junit.jupiter.api.BeforeEach; + +import org.springframework.cglib.proxy.Enhancer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Mikaël Francoeur + * @since 6.2 + * @see JdkProxyExceptionHandlingTests + */ +class CglibProxyExceptionHandlingTests extends AbstractProxyExceptionHandlingTests { + + @BeforeEach + void setup() { + proxyFactory.setProxyTargetClass(true); + } + + @Override + protected void assertProxyType(Object proxy) { + assertThat(Enhancer.isEnhanced(proxy.getClass())).isTrue(); + } + +} diff --git a/spring-aop/src/test/java/org/springframework/aop/framework/JdkProxyExceptionHandlingTests.java b/spring-aop/src/test/java/org/springframework/aop/framework/JdkProxyExceptionHandlingTests.java new file mode 100644 index 000000000000..a2df44f0e386 --- /dev/null +++ b/spring-aop/src/test/java/org/springframework/aop/framework/JdkProxyExceptionHandlingTests.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.aop.framework; + +import java.lang.reflect.Proxy; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Mikaël Francoeur + * @since 6.2 + * @see CglibProxyExceptionHandlingTests + */ +class JdkProxyExceptionHandlingTests extends AbstractProxyExceptionHandlingTests { + + @Override + protected void assertProxyType(Object proxy) { + assertThat(Proxy.isProxyClass(proxy.getClass())).isTrue(); + } + +} diff --git a/spring-aop/src/test/java/org/springframework/aop/framework/ProxyFactoryTests.java b/spring-aop/src/test/java/org/springframework/aop/framework/ProxyFactoryTests.java index d7ec7136b50a..476681fee264 100644 --- a/spring-aop/src/test/java/org/springframework/aop/framework/ProxyFactoryTests.java +++ b/spring-aop/src/test/java/org/springframework/aop/framework/ProxyFactoryTests.java @@ -19,16 +19,10 @@ import java.sql.SQLException; import java.sql.Savepoint; import java.util.ArrayList; -import java.util.Date; import java.util.List; -import javax.accessibility.Accessible; -import javax.swing.JFrame; -import javax.swing.RootPaneContainer; - import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.aop.Advisor; @@ -60,7 +54,7 @@ class ProxyFactoryTests { @Test - void testIndexOfMethods() { + void indexOfMethods() { TestBean target = new TestBean(); ProxyFactory pf = new ProxyFactory(target); NopInterceptor nop = new NopInterceptor(); @@ -76,7 +70,7 @@ void testIndexOfMethods() { } @Test - void testRemoveAdvisorByReference() { + void removeAdvisorByReference() { TestBean target = new TestBean(); ProxyFactory pf = new ProxyFactory(target); NopInterceptor nop = new NopInterceptor(); @@ -96,7 +90,7 @@ void testRemoveAdvisorByReference() { } @Test - void testRemoveAdvisorByIndex() { + void removeAdvisorByIndex() { TestBean target = new TestBean(); ProxyFactory pf = new ProxyFactory(target); NopInterceptor nop = new NopInterceptor(); @@ -144,7 +138,7 @@ void testRemoveAdvisorByIndex() { } @Test - void testReplaceAdvisor() { + void replaceAdvisor() { TestBean target = new TestBean(); ProxyFactory pf = new ProxyFactory(target); NopInterceptor nop = new NopInterceptor(); @@ -173,7 +167,7 @@ void testReplaceAdvisor() { } @Test - void testAddRepeatedInterface() { + void addRepeatedInterface() { TimeStamped tst = () -> { throw new UnsupportedOperationException("getTimeStamp"); }; @@ -186,7 +180,7 @@ void testAddRepeatedInterface() { } @Test - void testGetsAllInterfaces() { + void getsAllInterfaces() { // Extend to get new interface class TestBeanSubclass extends TestBean implements Comparable { @Override @@ -195,32 +189,29 @@ public int compareTo(Object arg0) { } } TestBeanSubclass raw = new TestBeanSubclass(); - ProxyFactory factory = new ProxyFactory(raw); - //System.out.println("Proxied interfaces are " + StringUtils.arrayToDelimitedString(factory.getProxiedInterfaces(), ",")); - assertThat(factory.getProxiedInterfaces()).as("Found correct number of interfaces").hasSize(5); - ITestBean tb = (ITestBean) factory.getProxy(); + ProxyFactory pf = new ProxyFactory(raw); + assertThat(pf.getProxiedInterfaces()).as("Found correct number of interfaces").hasSize(5); + ITestBean tb = (ITestBean) pf.getProxy(); assertThat(tb).as("Picked up secondary interface").isInstanceOf(IOther.class); raw.setAge(25); assertThat(tb.getAge()).isEqualTo(raw.getAge()); + Class[] oldProxiedInterfaces = pf.getProxiedInterfaces(); long t = 555555L; TimestampIntroductionInterceptor ti = new TimestampIntroductionInterceptor(t); + pf.addAdvisor(new DefaultIntroductionAdvisor(ti, TimeStamped.class)); - Class[] oldProxiedInterfaces = factory.getProxiedInterfaces(); - - factory.addAdvisor(0, new DefaultIntroductionAdvisor(ti, TimeStamped.class)); - - Class[] newProxiedInterfaces = factory.getProxiedInterfaces(); + Class[] newProxiedInterfaces = pf.getProxiedInterfaces(); assertThat(newProxiedInterfaces).as("Advisor proxies one more interface after introduction").hasSize(oldProxiedInterfaces.length + 1); - TimeStamped ts = (TimeStamped) factory.getProxy(); + TimeStamped ts = (TimeStamped) pf.getProxy(); assertThat(ts.getTimeStamp()).isEqualTo(t); // Shouldn't fail; ((IOther) ts).absquatulate(); } @Test - void testInterceptorInclusionMethods() { + void interceptorInclusionMethods() { class MyInterceptor implements MethodInterceptor { @Override public Object invoke(MethodInvocation invocation) { @@ -230,26 +221,26 @@ public Object invoke(MethodInvocation invocation) { NopInterceptor di = new NopInterceptor(); NopInterceptor diUnused = new NopInterceptor(); - ProxyFactory factory = new ProxyFactory(new TestBean()); - factory.addAdvice(0, di); - assertThat(factory.getProxy()).isInstanceOf(ITestBean.class); - assertThat(factory.adviceIncluded(di)).isTrue(); - assertThat(factory.adviceIncluded(diUnused)).isFalse(); - assertThat(factory.countAdvicesOfType(NopInterceptor.class)).isEqualTo(1); - assertThat(factory.countAdvicesOfType(MyInterceptor.class)).isEqualTo(0); - - factory.addAdvice(0, diUnused); - assertThat(factory.adviceIncluded(diUnused)).isTrue(); - assertThat(factory.countAdvicesOfType(NopInterceptor.class)).isEqualTo(2); + ProxyFactory pf = new ProxyFactory(new TestBean()); + pf.addAdvice(0, di); + assertThat(pf.getProxy()).isInstanceOf(ITestBean.class); + assertThat(pf.adviceIncluded(di)).isTrue(); + assertThat(pf.adviceIncluded(diUnused)).isFalse(); + assertThat(pf.countAdvicesOfType(NopInterceptor.class)).isEqualTo(1); + assertThat(pf.countAdvicesOfType(MyInterceptor.class)).isEqualTo(0); + + pf.addAdvice(0, diUnused); + assertThat(pf.adviceIncluded(diUnused)).isTrue(); + assertThat(pf.countAdvicesOfType(NopInterceptor.class)).isEqualTo(2); } @Test - void testSealedInterfaceExclusion() { + void sealedInterfaceExclusion() { // String implements ConstantDesc on JDK 12+, sealed as of JDK 17 - ProxyFactory factory = new ProxyFactory(""); + ProxyFactory pf = new ProxyFactory(""); NopInterceptor di = new NopInterceptor(); - factory.addAdvice(0, di); - Object proxy = factory.getProxy(); + pf.addAdvice(0, di); + Object proxy = pf.getProxy(); assertThat(proxy).isInstanceOf(CharSequence.class); } @@ -257,7 +248,7 @@ void testSealedInterfaceExclusion() { * Should see effect immediately on behavior. */ @Test - void testCanAddAndRemoveAspectInterfacesOnSingleton() { + void canAddAndRemoveAspectInterfacesOnSingleton() { ProxyFactory config = new ProxyFactory(new TestBean()); assertThat(config.getProxy()).as("Shouldn't implement TimeStamped before manipulation") @@ -304,7 +295,7 @@ void testCanAddAndRemoveAspectInterfacesOnSingleton() { } @Test - void testProxyTargetClassWithInterfaceAsTarget() { + void proxyTargetClassWithInterfaceAsTarget() { ProxyFactory pf = new ProxyFactory(); pf.setTargetClass(ITestBean.class); Object proxy = pf.getProxy(); @@ -320,7 +311,7 @@ void testProxyTargetClassWithInterfaceAsTarget() { } @Test - void testProxyTargetClassWithConcreteClassAsTarget() { + void proxyTargetClassWithConcreteClassAsTarget() { ProxyFactory pf = new ProxyFactory(); pf.setTargetClass(TestBean.class); Object proxy = pf.getProxy(); @@ -337,17 +328,46 @@ void testProxyTargetClassWithConcreteClassAsTarget() { } @Test - @Disabled("Not implemented yet, see https://jira.springframework.org/browse/SPR-5708") - public void testExclusionOfNonPublicInterfaces() { - JFrame frame = new JFrame(); - ProxyFactory proxyFactory = new ProxyFactory(frame); - Object proxy = proxyFactory.getProxy(); - assertThat(proxy).isInstanceOf(RootPaneContainer.class); - assertThat(proxy).isInstanceOf(Accessible.class); + void proxyTargetClassInCaseOfIntroducedInterface() { + ProxyFactory pf = new ProxyFactory(); + pf.setTargetClass(MyDate.class); + TimestampIntroductionInterceptor ti = new TimestampIntroductionInterceptor(0L); + pf.addAdvisor(new DefaultIntroductionAdvisor(ti, TimeStamped.class)); + Object proxy = pf.getProxy(); + assertThat(AopUtils.isCglibProxy(proxy)).as("Proxy is a CGLIB proxy").isTrue(); + assertThat(proxy).isInstanceOf(MyDate.class); + assertThat(proxy).isInstanceOf(TimeStamped.class); + assertThat(AopProxyUtils.ultimateTargetClass(proxy)).isEqualTo(MyDate.class); + } + + @Test + void proxyInterfaceInCaseOfIntroducedInterfaceOnly() { + ProxyFactory pf = new ProxyFactory(); + pf.addInterface(TimeStamped.class); + TimestampIntroductionInterceptor ti = new TimestampIntroductionInterceptor(0L); + pf.addAdvisor(new DefaultIntroductionAdvisor(ti, TimeStamped.class)); + Object proxy = pf.getProxy(); + assertThat(AopUtils.isJdkDynamicProxy(proxy)).as("Proxy is a JDK proxy").isTrue(); + assertThat(proxy).isInstanceOf(TimeStamped.class); + assertThat(AopProxyUtils.ultimateTargetClass(proxy)).isEqualTo(proxy.getClass()); + } + + @Test + void proxyInterfaceInCaseOfNonTargetInterface() { + ProxyFactory pf = new ProxyFactory(); + pf.setTargetClass(MyDate.class); + pf.addInterface(TimeStamped.class); + pf.addAdvice((MethodInterceptor) invocation -> { + throw new UnsupportedOperationException(); + }); + Object proxy = pf.getProxy(); + assertThat(AopUtils.isJdkDynamicProxy(proxy)).as("Proxy is a JDK proxy").isTrue(); + assertThat(proxy).isInstanceOf(TimeStamped.class); + assertThat(AopProxyUtils.ultimateTargetClass(proxy)).isEqualTo(MyDate.class); } @Test - void testInterfaceProxiesCanBeOrderedThroughAnnotations() { + void interfaceProxiesCanBeOrderedThroughAnnotations() { Object proxy1 = new ProxyFactory(new A()).getProxy(); Object proxy2 = new ProxyFactory(new B()).getProxy(); List list = new ArrayList<>(2); @@ -358,7 +378,7 @@ void testInterfaceProxiesCanBeOrderedThroughAnnotations() { } @Test - void testTargetClassProxiesCanBeOrderedThroughAnnotations() { + void targetClassProxiesCanBeOrderedThroughAnnotations() { ProxyFactory pf1 = new ProxyFactory(new A()); pf1.setProxyTargetClass(true); ProxyFactory pf2 = new ProxyFactory(new B()); @@ -373,7 +393,7 @@ void testTargetClassProxiesCanBeOrderedThroughAnnotations() { } @Test - void testInterceptorWithoutJoinpoint() { + void interceptorWithoutJoinpoint() { final TestBean target = new TestBean("tb"); ITestBean proxy = ProxyFactory.getProxy(ITestBean.class, (MethodInterceptor) invocation -> { assertThat(invocation.getThis()).isNull(); @@ -383,28 +403,28 @@ void testInterceptorWithoutJoinpoint() { } @Test - void testCharSequenceProxy() { + void interfaceProxy() { CharSequence target = "test"; ProxyFactory pf = new ProxyFactory(target); ClassLoader cl = target.getClass().getClassLoader(); CharSequence proxy = (CharSequence) pf.getProxy(cl); - assertThat(proxy.toString()).isEqualTo(target); + assertThat(proxy).asString().isEqualTo(target); assertThat(pf.getProxyClass(cl)).isSameAs(proxy.getClass()); } @Test - void testDateProxy() { - Date target = new Date(); + void dateProxy() { + MyDate target = new MyDate(); ProxyFactory pf = new ProxyFactory(target); pf.setProxyTargetClass(true); ClassLoader cl = target.getClass().getClassLoader(); - Date proxy = (Date) pf.getProxy(cl); + MyDate proxy = (MyDate) pf.getProxy(cl); assertThat(proxy.getTime()).isEqualTo(target.getTime()); assertThat(pf.getProxyClass(cl)).isSameAs(proxy.getClass()); } @Test - void testJdbcSavepointProxy() throws SQLException { + void jdbcSavepointProxy() throws SQLException { Savepoint target = new Savepoint() { @Override public int getSavepointId() { @@ -423,8 +443,20 @@ public String getSavepointName() { } + // Emulates java.util.Date locally, since we cannot automatically proxy the + // java.util.Date class. + static class MyDate { + + private final long time = System.currentTimeMillis(); + + public long getTime() { + return time; + } + } + + @Order(2) - public static class A implements Runnable { + static class A implements Runnable { @Override public void run() { @@ -433,7 +465,7 @@ public void run() { @Order(1) - public static class B implements Runnable { + static class B implements Runnable { @Override public void run() { diff --git a/spring-aop/src/test/java/org/springframework/aop/interceptor/AsyncExecutionInterceptorTests.java b/spring-aop/src/test/java/org/springframework/aop/interceptor/AsyncExecutionInterceptorTests.java new file mode 100644 index 000000000000..e18f4d24b8c6 --- /dev/null +++ b/spring-aop/src/test/java/org/springframework/aop/interceptor/AsyncExecutionInterceptorTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.aop.interceptor; + +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +import org.aopalliance.intercept.MethodInvocation; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.core.task.AsyncTaskExecutor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + + +/** + * Tests for {@link AsyncExecutionInterceptor}. + * + * @author Bao Ngo + * @since 7.0 + */ +class AsyncExecutionInterceptorTests { + + @Test + @SuppressWarnings("unchecked") + void invokeOnInterfaceWithGeneric() throws Throwable { + AsyncExecutionInterceptor interceptor = spy(new AsyncExecutionInterceptor(null)); + FutureRunner impl = new FutureRunner(); + MethodInvocation mi = mock(); + given(mi.getThis()).willReturn(impl); + given(mi.getMethod()).willReturn(GenericRunner.class.getMethod("run")); + + interceptor.invoke(mi); + ArgumentCaptor> classArgumentCaptor = ArgumentCaptor.forClass(Class.class); + verify(interceptor).doSubmit(any(Callable.class), any(AsyncTaskExecutor.class), classArgumentCaptor.capture()); + assertThat(classArgumentCaptor.getValue()).isEqualTo(Future.class); + } + + + interface GenericRunner { + + O run(); + } + + static class FutureRunner implements GenericRunner> { + @Override + public Future run() { + return CompletableFuture.runAsync(() -> { + }); + } + } +} diff --git a/spring-aop/src/test/java/org/springframework/aop/support/ClassUtilsTests.java b/spring-aop/src/test/java/org/springframework/aop/support/ClassUtilsTests.java index f1fffbdf9cb4..58b41a6500e8 100644 --- a/spring-aop/src/test/java/org/springframework/aop/support/ClassUtilsTests.java +++ b/spring-aop/src/test/java/org/springframework/aop/support/ClassUtilsTests.java @@ -25,6 +25,8 @@ import static org.assertj.core.api.Assertions.assertThat; /** + * AOP-specific tests for {@link ClassUtils}. + * * @author Colin Sampaleanu * @author Juergen Hoeller * @author Rob Harrop diff --git a/spring-aop/src/test/java/org/springframework/aop/support/ControlFlowPointcutTests.java b/spring-aop/src/test/java/org/springframework/aop/support/ControlFlowPointcutTests.java index 65c08b8725f5..a291b4a6e6a1 100644 --- a/spring-aop/src/test/java/org/springframework/aop/support/ControlFlowPointcutTests.java +++ b/spring-aop/src/test/java/org/springframework/aop/support/ControlFlowPointcutTests.java @@ -93,7 +93,7 @@ void controlFlowPointcutIsExtensible() { /** * Check that we can use a cflow pointcut in conjunction with - * a static pointcut: e.g. all setter methods that are invoked under + * a static pointcut: for example, all setter methods that are invoked under * a particular class. This greatly reduces the number of calls * to the cflow pointcut, meaning that it's not so prohibitively * expensive. diff --git a/spring-aop/src/test/kotlin/org/springframework/aop/framework/CglibAopProxyKotlinTests.kt b/spring-aop/src/test/kotlin/org/springframework/aop/framework/CglibAopProxyKotlinTests.kt new file mode 100644 index 000000000000..51dbfbbb8345 --- /dev/null +++ b/spring-aop/src/test/kotlin/org/springframework/aop/framework/CglibAopProxyKotlinTests.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.aop.framework + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +/** + * Tests for Kotlin support in [CglibAopProxy]. + * + * @author Sebastien Deleuze + */ +class CglibAopProxyKotlinTests { + + @Test + fun proxiedInvocation() { + val proxyFactory = ProxyFactory(MyKotlinBean()) + val proxy = proxyFactory.proxy as MyKotlinBean + assertThat(proxy.capitalize("foo")).isEqualTo("FOO") + } + + @Test + fun proxiedUncheckedException() { + val proxyFactory = ProxyFactory(MyKotlinBean()) + val proxy = proxyFactory.proxy as MyKotlinBean + assertThatThrownBy { proxy.uncheckedException() }.isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun proxiedCheckedException() { + val proxyFactory = ProxyFactory(MyKotlinBean()) + val proxy = proxyFactory.proxy as MyKotlinBean + assertThatThrownBy { proxy.checkedException() }.isInstanceOf(CheckedException::class.java) + } + + + open class MyKotlinBean { + + open fun capitalize(value: String) = value.uppercase() + + open fun uncheckedException() { + throw IllegalStateException() + } + + open fun checkedException() { + throw CheckedException() + } + } + + class CheckedException() : Exception() +} diff --git a/spring-aspects/src/main/java/org/springframework/transaction/aspectj/AspectJJtaTransactionManagementConfiguration.java b/spring-aspects/src/main/java/org/springframework/transaction/aspectj/AspectJJtaTransactionManagementConfiguration.java index 0ed7ffb69eb8..fc51788cd015 100644 --- a/spring-aspects/src/main/java/org/springframework/transaction/aspectj/AspectJJtaTransactionManagementConfiguration.java +++ b/spring-aspects/src/main/java/org/springframework/transaction/aspectj/AspectJJtaTransactionManagementConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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.transaction.annotation.EnableTransactionManagement; import org.springframework.transaction.annotation.TransactionManagementConfigurationSelector; import org.springframework.transaction.config.TransactionManagementConfigUtils; +import org.springframework.transaction.interceptor.TransactionAttributeSource; /** * {@code @Configuration} class that registers the Spring infrastructure beans necessary @@ -35,14 +36,15 @@ * @see EnableTransactionManagement * @see TransactionManagementConfigurationSelector */ -@Configuration +@Configuration(proxyBeanMethods = false) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public class AspectJJtaTransactionManagementConfiguration extends AspectJTransactionManagementConfiguration { @Bean(name = TransactionManagementConfigUtils.JTA_TRANSACTION_ASPECT_BEAN_NAME) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - public JtaAnnotationTransactionAspect jtaTransactionAspect() { + public JtaAnnotationTransactionAspect jtaTransactionAspect(TransactionAttributeSource transactionAttributeSource) { JtaAnnotationTransactionAspect txAspect = JtaAnnotationTransactionAspect.aspectOf(); + txAspect.setTransactionAttributeSource(transactionAttributeSource); if (this.txManager != null) { txAspect.setTransactionManager(this.txManager); } diff --git a/spring-aspects/src/main/java/org/springframework/transaction/aspectj/AspectJTransactionManagementConfiguration.java b/spring-aspects/src/main/java/org/springframework/transaction/aspectj/AspectJTransactionManagementConfiguration.java index 2c99c3050744..4e82c4524a7a 100644 --- a/spring-aspects/src/main/java/org/springframework/transaction/aspectj/AspectJTransactionManagementConfiguration.java +++ b/spring-aspects/src/main/java/org/springframework/transaction/aspectj/AspectJTransactionManagementConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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.transaction.annotation.EnableTransactionManagement; import org.springframework.transaction.annotation.TransactionManagementConfigurationSelector; import org.springframework.transaction.config.TransactionManagementConfigUtils; +import org.springframework.transaction.interceptor.TransactionAttributeSource; /** * {@code @Configuration} class that registers the Spring infrastructure beans necessary @@ -37,14 +38,15 @@ * @see TransactionManagementConfigurationSelector * @see AspectJJtaTransactionManagementConfiguration */ -@Configuration +@Configuration(proxyBeanMethods = false) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public class AspectJTransactionManagementConfiguration extends AbstractTransactionManagementConfiguration { @Bean(name = TransactionManagementConfigUtils.TRANSACTION_ASPECT_BEAN_NAME) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - public AnnotationTransactionAspect transactionAspect() { + public AnnotationTransactionAspect transactionAspect(TransactionAttributeSource transactionAttributeSource) { AnnotationTransactionAspect txAspect = AnnotationTransactionAspect.aspectOf(); + txAspect.setTransactionAttributeSource(transactionAttributeSource); if (this.txManager != null) { txAspect.setTransactionManager(this.txManager); } diff --git a/spring-aspects/src/test/java/org/springframework/cache/aspectj/AspectJEnableCachingIsolatedTests.java b/spring-aspects/src/test/java/org/springframework/cache/aspectj/AspectJEnableCachingIsolatedTests.java index 75647051d42a..bbc972dbc078 100644 --- a/spring-aspects/src/test/java/org/springframework/cache/aspectj/AspectJEnableCachingIsolatedTests.java +++ b/spring-aspects/src/test/java/org/springframework/cache/aspectj/AspectJEnableCachingIsolatedTests.java @@ -20,6 +20,8 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.NoUniqueBeanDefinitionException; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CachingConfigurer; import org.springframework.cache.annotation.EnableCaching; @@ -91,8 +93,11 @@ void multipleCacheManagerBeans() { try { load(MultiCacheManagerConfig.class); } - catch (IllegalStateException ex) { - assertThat(ex.getMessage()).contains("bean of type CacheManager"); + catch (NoUniqueBeanDefinitionException ex) { + assertThat(ex.getMessage()).contains( + "no CacheResolver specified and expected single matching CacheManager but found 2: cm1,cm2"); + assertThat(ex.getNumberOfBeansFound()).isEqualTo(2); + assertThat(ex.getBeanNamesFound()).containsExactly("cm1", "cm2"); } } @@ -116,8 +121,8 @@ void noCacheManagerBeans() { try { load(EmptyConfig.class); } - catch (IllegalStateException ex) { - assertThat(ex.getMessage()).contains("no bean of type CacheManager"); + catch (NoSuchBeanDefinitionException ex) { + assertThat(ex.getMessage()).contains("no CacheResolver specified"); } } diff --git a/spring-beans/src/jmh/kotlin/org/springframework/beans/KotlinBeanUtilsBenchmark.kt b/spring-beans/src/jmh/kotlin/org/springframework/beans/KotlinBeanUtilsBenchmark.kt index 44fd7e654c0e..3d06b4245423 100644 --- a/spring-beans/src/jmh/kotlin/org/springframework/beans/KotlinBeanUtilsBenchmark.kt +++ b/spring-beans/src/jmh/kotlin/org/springframework/beans/KotlinBeanUtilsBenchmark.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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 04fc76399ad1..4c8dcc48dd0c 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-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -474,7 +474,7 @@ private void processLocalProperty(PropertyTokenHolder tokens, PropertyValue pv) else { Throwable cause = ex.getTargetException(); if (cause instanceof UndeclaredThrowableException) { - // May happen e.g. with Groovy-generated methods + // May happen, for example, with Groovy-generated methods cause = cause.getCause(); } throw new MethodInvocationException(propertyChangeEvent, cause); @@ -658,6 +658,14 @@ else if (value instanceof List list) { growCollectionIfNecessary(list, index, indexedPropertyName.toString(), ph, i + 1); value = list.get(index); } + else if (value instanceof Map map) { + Class mapKeyType = ph.getResolvableType().getNested(i + 1).asMap().resolveGeneric(0); + // IMPORTANT: Do not pass full property name in here - property editors + // must not kick in for map keys but rather only for map values. + TypeDescriptor typeDescriptor = TypeDescriptor.valueOf(mapKeyType); + Object convertedMapKey = convertIfNecessary(null, null, key, mapKeyType, typeDescriptor); + value = map.get(convertedMapKey); + } else if (value instanceof Iterable iterable) { // Apply index to Iterator in case of a Set/Collection/Iterable. int index = Integer.parseInt(key); @@ -685,14 +693,6 @@ else if (value instanceof Iterable iterable) { currIndex + ", accessed using property path '" + propertyName + "'"); } } - else if (value instanceof Map map) { - Class mapKeyType = ph.getResolvableType().getNested(i + 1).asMap().resolveGeneric(0); - // IMPORTANT: Do not pass full property name in here - property editors - // must not kick in for map keys but rather only for map values. - TypeDescriptor typeDescriptor = TypeDescriptor.valueOf(mapKeyType); - Object convertedMapKey = convertIfNecessary(null, null, key, mapKeyType, typeDescriptor); - value = map.get(convertedMapKey); - } else { throw new InvalidPropertyException(getRootClass(), this.nestedPath + propertyName, "Property referenced in indexed property path '" + propertyName + @@ -904,16 +904,7 @@ private PropertyValue createDefaultPropertyValue(PropertyTokenHolder tokens) { private Object newValue(Class type, @Nullable TypeDescriptor desc, String name) { try { if (type.isArray()) { - Class componentType = type.componentType(); - // TODO - only handles 2-dimensional arrays - if (componentType.isArray()) { - Object array = Array.newInstance(componentType, 1); - Array.set(array, 0, Array.newInstance(componentType.componentType(), 0)); - return array; - } - else { - return Array.newInstance(componentType, 0); - } + return createArray(type); } else if (Collection.class.isAssignableFrom(type)) { TypeDescriptor elementDesc = (desc != null ? desc.getElementTypeDescriptor() : null); @@ -937,6 +928,24 @@ else if (Map.class.isAssignableFrom(type)) { } } + /** + * Create the array for the given array type. + * @param arrayType the desired type of the target array + * @return a new array instance + */ + private static Object createArray(Class arrayType) { + Assert.notNull(arrayType, "Array type must not be null"); + Class componentType = arrayType.componentType(); + if (componentType.isArray()) { + Object array = Array.newInstance(componentType, 1); + Array.set(array, 0, createArray(componentType)); + return array; + } + else { + return Array.newInstance(componentType, 0); + } + } + /** * Parse the given property name into the corresponding property name tokens. * @param propertyName the property name to parse diff --git a/spring-beans/src/main/java/org/springframework/beans/BeanInfoFactory.java b/spring-beans/src/main/java/org/springframework/beans/BeanInfoFactory.java index 3ad632b25439..395035467132 100644 --- a/spring-beans/src/main/java/org/springframework/beans/BeanInfoFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/BeanInfoFactory.java @@ -23,7 +23,7 @@ /** * Strategy interface for creating {@link BeanInfo} instances for Spring beans. - * Can be used to plug in custom bean property resolution strategies (e.g. for other + * Can be used to plug in custom bean property resolution strategies (for example, for other * languages on the JVM) or more efficient {@link BeanInfo} retrieval algorithms. * *

BeanInfoFactories are instantiated by the {@link CachedIntrospectionResults}, diff --git a/spring-beans/src/main/java/org/springframework/beans/BeanMetadataAttribute.java b/spring-beans/src/main/java/org/springframework/beans/BeanMetadataAttribute.java index f5c8a854ad48..b8d316db91ee 100644 --- a/spring-beans/src/main/java/org/springframework/beans/BeanMetadataAttribute.java +++ b/spring-beans/src/main/java/org/springframework/beans/BeanMetadataAttribute.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +22,8 @@ /** * Holder for a key-value style attribute that is part of a bean definition. - * Keeps track of the definition source in addition to the key-value pair. + * + *

Keeps track of the definition source in addition to the key-value pair. * * @author Juergen Hoeller * @since 2.5 @@ -39,7 +40,7 @@ public class BeanMetadataAttribute implements BeanMetadataElement { /** - * Create a new AttributeValue instance. + * Create a new {@code AttributeValue} instance. * @param name the name of the attribute (never {@code null}) * @param value the value of the attribute (possibly before type conversion) */ @@ -95,7 +96,7 @@ public int hashCode() { @Override public String toString() { - return "metadata attribute '" + this.name + "'"; + return "metadata attribute: name='" + this.name + "'; value=" + this.value; } } diff --git a/spring-beans/src/main/java/org/springframework/beans/BeanUtils.java b/spring-beans/src/main/java/org/springframework/beans/BeanUtils.java index cedf0408f2a3..f4ca77b3e1ca 100644 --- a/spring-beans/src/main/java/org/springframework/beans/BeanUtils.java +++ b/spring-beans/src/main/java/org/springframework/beans/BeanUtils.java @@ -23,6 +23,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.lang.reflect.RecordComponent; import java.lang.reflect.Type; import java.util.Arrays; import java.util.Collections; @@ -125,7 +126,7 @@ public static T instantiate(Class clazz) throws BeanInstantiationExceptio * The cause may notably indicate a {@link NoSuchMethodException} if no * primary/default constructor was found, a {@link NoClassDefFoundError} * or other {@link LinkageError} in case of an unresolvable class definition - * (e.g. due to a missing dependency at runtime), or an exception thrown + * (for example, due to a missing dependency at runtime), or an exception thrown * from the constructor invocation itself. * @see Constructor#newInstance */ @@ -224,9 +225,10 @@ public static T instantiateClass(Constructor ctor, Object... args) throws /** * Return a resolvable constructor for the provided class, either a primary or single - * public constructor with arguments, or a single non-public constructor with arguments, - * or simply a default constructor. Callers have to be prepared to resolve arguments - * for the returned constructor's parameters, if any. + * public constructor with arguments, a single non-public constructor with arguments + * or simply a default constructor. + *

Callers have to be prepared to resolve arguments for the returned constructor's + * parameters, if any. * @param clazz the class to check * @throws IllegalStateException in case of no unique constructor found at all * @since 5.3 @@ -248,7 +250,7 @@ else if (ctors.length == 0) { // No public constructors -> check non-public ctors = clazz.getDeclaredConstructors(); if (ctors.length == 1) { - // A single non-public constructor, e.g. from a non-public record type + // A single non-public constructor, for example, from a non-public record type return (Constructor) ctors[0]; } } @@ -268,11 +270,12 @@ else if (ctors.length == 0) { /** * Return the primary constructor of the provided class. For Kotlin classes, this * returns the Java constructor corresponding to the Kotlin primary constructor - * (as defined in the Kotlin specification). Otherwise, in particular for non-Kotlin - * classes, this simply returns {@code null}. + * (as defined in the Kotlin specification). For Java records, this returns the + * canonical constructor. Otherwise, this simply returns {@code null}. * @param clazz the class to check * @since 5.0 - * @see Kotlin docs + * @see Kotlin constructors + * @see Record constructor declarations */ @Nullable public static Constructor findPrimaryConstructor(Class clazz) { @@ -280,6 +283,19 @@ public static Constructor findPrimaryConstructor(Class clazz) { if (KotlinDetector.isKotlinReflectPresent() && KotlinDetector.isKotlinType(clazz)) { return KotlinDelegate.findPrimaryConstructor(clazz); } + if (clazz.isRecord()) { + try { + // Use the canonical constructor which is always present + RecordComponent[] components = clazz.getRecordComponents(); + Class[] paramTypes = new Class[components.length]; + for (int i = 0; i < components.length; i++) { + paramTypes[i] = components[i].getType(); + } + return clazz.getDeclaredConstructor(paramTypes); + } + catch (NoSuchMethodException ignored) { + } + } return null; } @@ -538,7 +554,7 @@ public static PropertyDescriptor findPropertyForMethod(Method method, Class c /** * Find a JavaBeans PropertyEditor following the 'Editor' suffix convention - * (e.g. "mypackage.MyDomainClass" → "mypackage.MyDomainClassEditor"). + * (for example, "mypackage.MyDomainClass" → "mypackage.MyDomainClassEditor"). *

Compatible to the standard JavaBeans convention as implemented by * {@link java.beans.PropertyEditorManager} but isolated from the latter's * registered default editors for primitive types. @@ -560,7 +576,7 @@ public static PropertyEditor findEditorByConvention(@Nullable Class targetTyp } } catch (Throwable ex) { - // e.g. AccessControlException on Google App Engine + // for example, AccessControlException on Google App Engine return null; } } diff --git a/spring-beans/src/main/java/org/springframework/beans/CachedIntrospectionResults.java b/spring-beans/src/main/java/org/springframework/beans/CachedIntrospectionResults.java index 96d9f94548df..f29a9d30407d 100644 --- a/spring-beans/src/main/java/org/springframework/beans/CachedIntrospectionResults.java +++ b/spring-beans/src/main/java/org/springframework/beans/CachedIntrospectionResults.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,6 @@ import java.lang.reflect.Modifier; import java.net.URL; import java.security.ProtectionDomain; -import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; @@ -87,8 +86,7 @@ public final class CachedIntrospectionResults { * Set of ClassLoaders that this CachedIntrospectionResults class will always * accept classes from, even if the classes do not qualify as cache-safe. */ - static final Set acceptedClassLoaders = - Collections.newSetFromMap(new ConcurrentHashMap<>(16)); + static final Set acceptedClassLoaders = ConcurrentHashMap.newKeySet(16); /** * Map keyed by Class containing CachedIntrospectionResults, strongly held. @@ -109,7 +107,7 @@ public final class CachedIntrospectionResults { * Accept the given ClassLoader as cache-safe, even if its classes would * not qualify as cache-safe in this CachedIntrospectionResults class. *

This configuration method is only relevant in scenarios where the Spring - * classes reside in a 'common' ClassLoader (e.g. the system ClassLoader) + * classes reside in a 'common' ClassLoader (for example, the system ClassLoader) * whose lifecycle is not coupled to the application. In such a scenario, * CachedIntrospectionResults would by default not cache any of the application's * classes, since they would create a leak in the common ClassLoader. @@ -292,7 +290,7 @@ private CachedIntrospectionResults(Class beanClass) throws BeansException { currClass = currClass.getSuperclass(); } - // Check for record-style accessors without prefix: e.g. "lastName()" + // Check for record-style accessors without prefix: for example, "lastName()" // - accessor method directly referring to instance field of same name // - same convention for component accessors of Java 15 record classes introspectPlainAccessors(beanClass, readMethodNames); diff --git a/spring-beans/src/main/java/org/springframework/beans/ExtendedBeanInfo.java b/spring-beans/src/main/java/org/springframework/beans/ExtendedBeanInfo.java index 804ef9d21b31..5cd244f80f9c 100644 --- a/spring-beans/src/main/java/org/springframework/beans/ExtendedBeanInfo.java +++ b/spring-beans/src/main/java/org/springframework/beans/ExtendedBeanInfo.java @@ -41,7 +41,7 @@ import org.springframework.util.ObjectUtils; /** - * Decorator for a standard {@link BeanInfo} object, e.g. as created by + * Decorator for a standard {@link BeanInfo} object, for example, as created by * {@link Introspector#getBeanInfo(Class)}, designed to discover and register * static and/or non-void returning setter methods. For example: * diff --git a/spring-beans/src/main/java/org/springframework/beans/FatalBeanException.java b/spring-beans/src/main/java/org/springframework/beans/FatalBeanException.java index 7c6e1d941cb5..78d9e5c62f39 100644 --- a/spring-beans/src/main/java/org/springframework/beans/FatalBeanException.java +++ b/spring-beans/src/main/java/org/springframework/beans/FatalBeanException.java @@ -20,7 +20,7 @@ /** * Thrown on an unrecoverable problem encountered in the - * beans packages or sub-packages, e.g. bad class or field. + * beans packages or sub-packages, for example, bad class or field. * * @author Rod Johnson */ diff --git a/spring-beans/src/main/java/org/springframework/beans/GenericTypeAwarePropertyDescriptor.java b/spring-beans/src/main/java/org/springframework/beans/GenericTypeAwarePropertyDescriptor.java index bc2faca99620..a8247f6e421c 100644 --- a/spring-beans/src/main/java/org/springframework/beans/GenericTypeAwarePropertyDescriptor.java +++ b/spring-beans/src/main/java/org/springframework/beans/GenericTypeAwarePropertyDescriptor.java @@ -106,9 +106,9 @@ public GenericTypeAwarePropertyDescriptor(Class beanClass, String propertyNam // by the JDK's JavaBeans Introspector... Set ambiguousCandidates = new HashSet<>(); for (Method method : beanClass.getMethods()) { - if (method.getName().equals(writeMethodToUse.getName()) && - !method.equals(writeMethodToUse) && !method.isBridge() && - method.getParameterCount() == writeMethodToUse.getParameterCount()) { + if (method.getName().equals(this.writeMethod.getName()) && + !method.equals(this.writeMethod) && !method.isBridge() && + method.getParameterCount() == this.writeMethod.getParameterCount()) { ambiguousCandidates.add(method); } } diff --git a/spring-beans/src/main/java/org/springframework/beans/PropertyEditorRegistrar.java b/spring-beans/src/main/java/org/springframework/beans/PropertyEditorRegistrar.java index 69e2a68b3e3c..f96972e81212 100644 --- a/spring-beans/src/main/java/org/springframework/beans/PropertyEditorRegistrar.java +++ b/spring-beans/src/main/java/org/springframework/beans/PropertyEditorRegistrar.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2012 the original author or authors. + * Copyright 2002-2025 the original author 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,4 +45,18 @@ public interface PropertyEditorRegistrar { */ void registerCustomEditors(PropertyEditorRegistry registry); + /** + * Indicate whether this registrar exclusively overrides default editors + * rather than registering custom editors, intended to be applied lazily. + *

This has an impact on registrar handling in a bean factory: see + * {@link org.springframework.beans.factory.config.ConfigurableBeanFactory#addPropertyEditorRegistrar}. + * @since 6.2.3 + * @see PropertyEditorRegistry#registerCustomEditor + * @see PropertyEditorRegistrySupport#overrideDefaultEditor + * @see PropertyEditorRegistrySupport#setDefaultEditorRegistrar + */ + default boolean overridesDefaultEditors() { + return false; + } + } diff --git a/spring-beans/src/main/java/org/springframework/beans/PropertyEditorRegistrySupport.java b/spring-beans/src/main/java/org/springframework/beans/PropertyEditorRegistrySupport.java index 9843e826d74a..0802b5788c75 100644 --- a/spring-beans/src/main/java/org/springframework/beans/PropertyEditorRegistrySupport.java +++ b/spring-beans/src/main/java/org/springframework/beans/PropertyEditorRegistrySupport.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author 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,9 @@ public class PropertyEditorRegistrySupport implements PropertyEditorRegistry { private boolean configValueEditorsActive = false; + @Nullable + private PropertyEditorRegistrar defaultEditorRegistrar; + @Nullable private Map, PropertyEditor> defaultEditors; @@ -155,6 +158,19 @@ public void useConfigValueEditors() { this.configValueEditorsActive = true; } + /** + * Set a registrar for default editors, as a lazy way of overriding default editors. + *

This is expected to be a collaborator with {@link PropertyEditorRegistrySupport}, + * downcasting the given {@link PropertyEditorRegistry} accordingly and calling + * {@link #overrideDefaultEditor} for registering additional default editors on it. + * @param registrar the registrar to call when default editors are actually needed + * @since 6.2.3 + * @see #overrideDefaultEditor + */ + public void setDefaultEditorRegistrar(PropertyEditorRegistrar registrar) { + this.defaultEditorRegistrar = registrar; + } + /** * Override the default editor for the specified type with the given property editor. *

Note that this is different from registering a custom editor in that the editor @@ -179,10 +195,14 @@ public void overrideDefaultEditor(Class requiredType, PropertyEditor property * @see #registerDefaultEditors */ @Nullable + @SuppressWarnings("NullAway") public PropertyEditor getDefaultEditor(Class requiredType) { if (!this.defaultEditorsActive) { return null; } + if (this.overriddenDefaultEditors == null && this.defaultEditorRegistrar != null) { + this.defaultEditorRegistrar.registerCustomEditors(this); + } if (this.overriddenDefaultEditors != null) { PropertyEditor editor = this.overriddenDefaultEditors.get(requiredType); if (editor != null) { diff --git a/spring-beans/src/main/java/org/springframework/beans/TypeConverterDelegate.java b/spring-beans/src/main/java/org/springframework/beans/TypeConverterDelegate.java index 2fc9486c50f6..f41724275445 100644 --- a/spring-beans/src/main/java/org/springframework/beans/TypeConverterDelegate.java +++ b/spring-beans/src/main/java/org/springframework/beans/TypeConverterDelegate.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -315,7 +315,7 @@ private Object attemptToConvertStringToEnum(Class requiredType, String trimme } if (convertedValue == currentConvertedValue) { - // Try field lookup as fallback: for JDK 1.5 enum or custom enum + // Try field lookup as fallback: for Java enum or custom enum // with values defined as static fields. Resulting value still needs // to be checked, hence we don't return it right away. try { diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/BeanCurrentlyInCreationException.java b/spring-beans/src/main/java/org/springframework/beans/factory/BeanCurrentlyInCreationException.java index 4c984fb12784..5f5fc7b99d30 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/BeanCurrentlyInCreationException.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/BeanCurrentlyInCreationException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2012 the original author or authors. + * Copyright 2002-2024 the original author 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,8 +32,8 @@ public class BeanCurrentlyInCreationException extends BeanCreationException { * @param beanName the name of the bean requested */ public BeanCurrentlyInCreationException(String beanName) { - super(beanName, - "Requested bean is currently in creation: Is there an unresolvable circular reference?"); + super(beanName, "Requested bean is currently in creation: "+ + "Is there an unresolvable circular reference or an asynchronous initialization dependency?"); } /** diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/BeanDefinitionStoreException.java b/spring-beans/src/main/java/org/springframework/beans/factory/BeanDefinitionStoreException.java index d807d5f90179..f97c34b086e2 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/BeanDefinitionStoreException.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/BeanDefinitionStoreException.java @@ -21,7 +21,7 @@ /** * Exception thrown when a BeanFactory encounters an invalid bean definition: - * e.g. in case of incomplete or contradictory bean metadata. + * for example, in case of incomplete or contradictory bean metadata. * * @author Rod Johnson * @author Juergen Hoeller diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/BeanFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/BeanFactory.java index 8973cf271e5e..fb1f3ffd9df2 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/BeanFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/BeanFactory.java @@ -36,7 +36,7 @@ * singleton in the scope of the factory). Which type of instance will be returned * depends on the bean factory configuration: the API is the same. Since Spring * 2.0, further scopes are available depending on the concrete application - * context (e.g. "request" and "session" scopes in a web environment). + * context (for example, "request" and "session" scopes in a web environment). * *

The point of this approach is that the BeanFactory is a central registry * of application components, and centralizes configuration of application diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/BeanFactoryInitializer.java b/spring-beans/src/main/java/org/springframework/beans/factory/BeanFactoryInitializer.java new file mode 100644 index 000000000000..30c08739bbbd --- /dev/null +++ b/spring-beans/src/main/java/org/springframework/beans/factory/BeanFactoryInitializer.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.beans.factory; + +/** + * Callback interface for initializing a Spring {@link ListableBeanFactory} + * prior to entering the singleton pre-instantiation phase. Can be used to + * trigger early initialization of specific beans before regular singletons. + * + *

Can be programmatically applied to a {@code ListableBeanFactory} instance. + * In an {@code ApplicationContext}, beans of type {@code BeanFactoryInitializer} + * will be autodetected and automatically applied to the underlying bean factory. + * + * @author Juergen Hoeller + * @since 6.2 + * @param the bean factory type + * @see org.springframework.beans.factory.config.ConfigurableListableBeanFactory#preInstantiateSingletons() + */ +public interface BeanFactoryInitializer { + + /** + * Initialize the given bean factory. + * @param beanFactory the bean factory to bootstrap + */ + void initialize(F beanFactory); + +} diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/BeanFactoryUtils.java b/spring-beans/src/main/java/org/springframework/beans/factory/BeanFactoryUtils.java index 079760177033..24227a3c1143 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/BeanFactoryUtils.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/BeanFactoryUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 the original author 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,10 +39,14 @@ * (which the methods defined on the ListableBeanFactory interface don't, * in contrast to the methods defined on the BeanFactory interface). * + *

NOTE: It is generally preferable to use {@link ObjectProvider#stream()} + * via {@link BeanFactory#getBeanProvider} instead of this utility class. + * * @author Rod Johnson * @author Juergen Hoeller * @author Chris Beams * @since 04.07.2003 + * @see BeanFactory#getBeanProvider */ public abstract class BeanFactoryUtils { @@ -308,7 +312,7 @@ public static String[] beanNamesForAnnotationIncludingAncestors( * 'replacing' beans by explicitly choosing the same bean name in a child factory; * the bean in the ancestor factory won't be visible then, not even for by-type lookups. * @param lbf the bean factory - * @param type type of bean to match + * @param type the type of bean to match * @return the Map of matching bean instances, or an empty Map if none * @throws BeansException if a bean could not be created * @see ListableBeanFactory#getBeansOfType(Class) @@ -347,7 +351,7 @@ public static Map beansOfTypeIncludingAncestors(ListableBeanFacto * 'replacing' beans by explicitly choosing the same bean name in a child factory; * the bean in the ancestor factory won't be visible then, not even for by-type lookups. * @param lbf the bean factory - * @param type type of bean to match + * @param type the type of bean to match * @param includeNonSingletons whether to include prototype or scoped beans too * or just singletons (also applies to FactoryBeans) * @param allowEagerInit whether to initialize lazy-init singletons and @@ -395,7 +399,7 @@ public static Map beansOfTypeIncludingAncestors( * 'replacing' beans by explicitly choosing the same bean name in a child factory; * the bean in the ancestor factory won't be visible then, not even for by-type lookups. * @param lbf the bean factory - * @param type type of bean to match + * @param type the type of bean to match * @return the matching bean instance * @throws NoSuchBeanDefinitionException if no bean of the given type was found * @throws NoUniqueBeanDefinitionException if more than one bean of the given type was found @@ -425,7 +429,7 @@ public static T beanOfTypeIncludingAncestors(ListableBeanFactory lbf, Class< * 'replacing' beans by explicitly choosing the same bean name in a child factory; * the bean in the ancestor factory won't be visible then, not even for by-type lookups. * @param lbf the bean factory - * @param type type of bean to match + * @param type the type of bean to match * @param includeNonSingletons whether to include prototype or scoped beans too * or just singletons (also applies to FactoryBeans) * @param allowEagerInit whether to initialize lazy-init singletons and @@ -457,7 +461,7 @@ public static T beanOfTypeIncludingAncestors( *

This version of {@code beanOfType} automatically includes * prototypes and FactoryBeans. * @param lbf the bean factory - * @param type type of bean to match + * @param type the type of bean to match * @return the matching bean instance * @throws NoSuchBeanDefinitionException if no bean of the given type was found * @throws NoUniqueBeanDefinitionException if more than one bean of the given type was found @@ -481,7 +485,7 @@ public static T beanOfType(ListableBeanFactory lbf, Class type) throws Be * only raw FactoryBeans will be checked (which doesn't require initialization * of each FactoryBean). * @param lbf the bean factory - * @param type type of bean to match + * @param type the type of bean to match * @param includeNonSingletons whether to include prototype or scoped beans too * or just singletons (also applies to FactoryBeans) * @param allowEagerInit whether to initialize lazy-init singletons and @@ -529,7 +533,7 @@ private static String[] mergeNamesWithParent(String[] result, String[] parentRes /** * Extract a unique bean for the given type from the given Map of matching beans. - * @param type type of bean to match + * @param type the type of bean to match * @param matchingBeans all matching beans found * @return the unique bean instance * @throws NoSuchBeanDefinitionException if no bean of the given type was found diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/FactoryBean.java b/spring-beans/src/main/java/org/springframework/beans/factory/FactoryBean.java index 97362ce1f7c9..c8d06fe3b01c 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/FactoryBean.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/FactoryBean.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +39,9 @@ * *

{@code FactoryBean} is a programmatic contract. Implementations are not * supposed to rely on annotation-driven injection or other reflective facilities. - * {@link #getObjectType()} {@link #getObject()} invocations may arrive early in the - * bootstrap process, even ahead of any post-processor setup. If you need access to - * other beans, implement {@link BeanFactoryAware} and obtain them programmatically. + * Invocations of {@link #getObjectType()} and {@link #getObject()} may arrive early + * in the bootstrap process, even ahead of any post-processor setup. If you need access + * to other beans, implement {@link BeanFactoryAware} and obtain them programmatically. * *

The container is only responsible for managing the lifecycle of the FactoryBean * instance, not the lifecycle of the objects created by the FactoryBean. Therefore, @@ -50,7 +50,7 @@ * {@link DisposableBean} and delegate any such close call to the underlying object. * *

Finally, FactoryBean objects participate in the containing BeanFactory's - * synchronization of bean creation. There is usually no need for internal + * synchronization of bean creation. Thus, there is usually no need for internal * synchronization other than for purposes of lazy initialization within the * FactoryBean itself (or the like). * @@ -68,7 +68,7 @@ public interface FactoryBean { * The name of an attribute that can be * {@link org.springframework.core.AttributeAccessor#setAttribute set} on a * {@link org.springframework.beans.factory.config.BeanDefinition} so that - * factory beans can signal their object type when it can't be deduced from + * factory beans can signal their object type when it cannot be deduced from * the factory bean class. * @since 5.2 */ @@ -79,15 +79,15 @@ public interface FactoryBean { * Return an instance (possibly shared or independent) of the object * managed by this factory. *

As with a {@link BeanFactory}, this allows support for both the - * Singleton and Prototype design pattern. + * Singleton and Prototype design patterns. *

If this FactoryBean is not fully initialized yet at the time of * the call (for example because it is involved in a circular reference), * throw a corresponding {@link FactoryBeanNotInitializedException}. - *

As of Spring 2.0, FactoryBeans are allowed to return {@code null} - * objects. The factory will consider this as normal value to be used; it - * will not throw a FactoryBeanNotInitializedException in this case anymore. + *

FactoryBeans are allowed to return {@code null} objects. The bean + * factory will consider this as a normal value to be used and will not throw + * a {@code FactoryBeanNotInitializedException} in this case. However, * FactoryBean implementations are encouraged to throw - * FactoryBeanNotInitializedException themselves now, as appropriate. + * {@code FactoryBeanNotInitializedException} themselves, as appropriate. * @return an instance of the bean (can be {@code null}) * @throws Exception in case of creation errors * @see FactoryBeanNotInitializedException @@ -100,7 +100,7 @@ public interface FactoryBean { * or {@code null} if not known in advance. *

This allows one to check for specific types of beans without * instantiating objects, for example on autowiring. - *

In the case of implementations that are creating a singleton object, + *

In the case of implementations that create a singleton object, * this method should try to avoid singleton creation as far as possible; * it should rather estimate the type in advance. * For prototypes, returning a meaningful type here is advisable too. @@ -121,8 +121,8 @@ public interface FactoryBean { * Is the object managed by this factory a singleton? That is, * will {@link #getObject()} always return the same object * (a reference that can be cached)? - *

NOTE: If a FactoryBean indicates to hold a singleton object, - * the object returned from {@code getObject()} might get cached + *

NOTE: If a FactoryBean indicates that it holds a singleton + * object, the object returned from {@code getObject()} might get cached * by the owning BeanFactory. Hence, do not return {@code true} * unless the FactoryBean always exposes the same reference. *

The singleton status of the FactoryBean itself will generally diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/InitializingBean.java b/spring-beans/src/main/java/org/springframework/beans/factory/InitializingBean.java index 940c2dd922a9..a062dc9e1aa9 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/InitializingBean.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/InitializingBean.java @@ -18,7 +18,7 @@ /** * Interface to be implemented by beans that need to react once all their properties - * have been set by a {@link BeanFactory}: e.g. to perform custom initialization, + * have been set by a {@link BeanFactory}: for example, to perform custom initialization, * or merely to check that all mandatory properties have been set. * *

An alternative to implementing {@code InitializingBean} is specifying a custom diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/NoUniqueBeanDefinitionException.java b/spring-beans/src/main/java/org/springframework/beans/factory/NoUniqueBeanDefinitionException.java index 9e30f2f72c59..c2a6070c9d3f 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/NoUniqueBeanDefinitionException.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/NoUniqueBeanDefinitionException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +29,7 @@ * multiple matching candidates have been found when only one matching bean was expected. * * @author Juergen Hoeller + * @author Stephane Nicoll * @since 3.2.1 * @see BeanFactory#getBean(Class) */ @@ -41,6 +42,19 @@ public class NoUniqueBeanDefinitionException extends NoSuchBeanDefinitionExcepti private final Collection beanNamesFound; + /** + * Create a new {@code NoUniqueBeanDefinitionException}. + * @param type required type of the non-unique bean + * @param beanNamesFound the names of all matching beans (as a Collection) + * @param message detailed message describing the problem + * @since 6.2 + */ + public NoUniqueBeanDefinitionException(Class type, Collection beanNamesFound, String message) { + super(type, message); + this.numberOfBeansFound = beanNamesFound.size(); + this.beanNamesFound = new ArrayList<>(beanNamesFound); + } + /** * Create a new {@code NoUniqueBeanDefinitionException}. * @param type required type of the non-unique bean @@ -59,10 +73,8 @@ public NoUniqueBeanDefinitionException(Class type, int numberOfBeansFound, St * @param beanNamesFound the names of all matching beans (as a Collection) */ public NoUniqueBeanDefinitionException(Class type, Collection beanNamesFound) { - super(type, "expected single matching bean but found " + beanNamesFound.size() + ": " + + this(type, beanNamesFound, "expected single matching bean but found " + beanNamesFound.size() + ": " + StringUtils.collectionToCommaDelimitedString(beanNamesFound)); - this.numberOfBeansFound = beanNamesFound.size(); - this.beanNamesFound = new ArrayList<>(beanNamesFound); } /** diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/ObjectProvider.java b/spring-beans/src/main/java/org/springframework/beans/factory/ObjectProvider.java index a9dc61eea426..492d224a9cb9 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/ObjectProvider.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/ObjectProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2025 the original author 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,20 +18,34 @@ import java.util.Iterator; import java.util.function.Consumer; +import java.util.function.Predicate; import java.util.function.Supplier; import java.util.stream.Stream; import org.springframework.beans.BeansException; +import org.springframework.core.OrderComparator; import org.springframework.lang.Nullable; /** * A variant of {@link ObjectFactory} designed specifically for injection points, * allowing for programmatic optionality and lenient not-unique handling. * + *

In a {@link BeanFactory} environment, every {@code ObjectProvider} obtained + * from the factory will be bound to its {@code BeanFactory} for a specific bean + * type, matching all provider calls against factory-registered bean definitions. + * Note that all such calls dynamically operate on the underlying factory state, + * freshly resolving the requested target object on every call. + * *

As of 5.1, this interface extends {@link Iterable} and provides {@link Stream} * support. It can be therefore be used in {@code for} loops, provides {@link #forEach} * iteration and allows for collection-style {@link #stream} access. * + *

As of 6.2, this interface declares default implementations for all methods. + * This makes it easier to implement in a custom fashion, for example, for unit tests. + * For typical purposes, implement {@link #stream()} to enable all other methods. + * Alternatively, you may implement the specific methods that your callers expect, + * for example, just {@link #getObject()} or {@link #getIfAvailable()}. + * * @author Juergen Hoeller * @since 4.3 * @param the object type @@ -40,6 +54,31 @@ */ public interface ObjectProvider extends ObjectFactory, Iterable { + /** + * A predicate for unfiltered type matches, including non-default candidates + * but still excluding non-autowire candidates when used on injection points. + * @since 6.2.3 + * @see #stream(Predicate) + * @see #orderedStream(Predicate) + * @see org.springframework.beans.factory.config.BeanDefinition#isAutowireCandidate() + * @see org.springframework.beans.factory.support.AbstractBeanDefinition#isDefaultCandidate() + */ + Predicate> UNFILTERED = (clazz -> true); + + + @Override + default T getObject() throws BeansException { + Iterator it = iterator(); + if (!it.hasNext()) { + throw new NoSuchBeanDefinitionException(Object.class); + } + T result = it.next(); + if (it.hasNext()) { + throw new NoUniqueBeanDefinitionException(Object.class, 2, "more than 1 matching bean"); + } + return result; + } + /** * Return an instance (possibly shared or independent) of the object * managed by this factory. @@ -50,7 +89,10 @@ public interface ObjectProvider extends ObjectFactory, Iterable { * @throws BeansException in case of creation errors * @see #getObject() */ - T getObject(Object... args) throws BeansException; + default T getObject(Object... args) throws BeansException { + throw new UnsupportedOperationException("Retrieval with arguments not supported -" + + "for custom ObjectProvider classes, implement getObject(Object...) for your purposes"); + } /** * Return an instance (possibly shared or independent) of the object @@ -60,7 +102,17 @@ public interface ObjectProvider extends ObjectFactory, Iterable { * @see #getObject() */ @Nullable - T getIfAvailable() throws BeansException; + default T getIfAvailable() throws BeansException { + try { + return getObject(); + } + catch (NoUniqueBeanDefinitionException ex) { + throw ex; + } + catch (NoSuchBeanDefinitionException ex) { + return null; + } + } /** * Return an instance (possibly shared or independent) of the object @@ -103,7 +155,14 @@ default void ifAvailable(Consumer dependencyConsumer) throws BeansException { * @see #getObject() */ @Nullable - T getIfUnique() throws BeansException; + default T getIfUnique() throws BeansException { + try { + return getObject(); + } + catch (NoSuchBeanDefinitionException ex) { + return null; + } + } /** * Return an instance (possibly shared or independent) of the object @@ -152,12 +211,17 @@ default Iterator iterator() { /** * Return a sequential {@link Stream} over all matching object instances, * without specific ordering guarantees (but typically in registration order). + *

Note: The result may be filtered by default according to qualifiers on the + * injection point versus target beans and the general autowire candidate status + * of matching beans. For custom filtering against type-matching candidates, use + * {@link #stream(Predicate)} instead (potentially with {@link #UNFILTERED}). * @since 5.1 * @see #iterator() * @see #orderedStream() */ default Stream stream() { - throw new UnsupportedOperationException("Multi element access not supported"); + throw new UnsupportedOperationException("Element access not supported - " + + "for custom ObjectProvider classes, implement stream() to enable all other methods"); } /** @@ -168,12 +232,48 @@ default Stream stream() { * and in case of annotation-based configuration also considering the * {@link org.springframework.core.annotation.Order} annotation, * analogous to multi-element injection points of list/array type. + *

The default method applies an {@link OrderComparator} to the + * {@link #stream()} method. You may override this to apply an + * {@link org.springframework.core.annotation.AnnotationAwareOrderComparator} + * if necessary. + *

Note: The result may be filtered by default according to qualifiers on the + * injection point versus target beans and the general autowire candidate status + * of matching beans. For custom filtering against type-matching candidates, use + * {@link #stream(Predicate)} instead (potentially with {@link #UNFILTERED}). * @since 5.1 * @see #stream() * @see org.springframework.core.OrderComparator */ default Stream orderedStream() { - throw new UnsupportedOperationException("Ordered element access not supported"); + return stream().sorted(OrderComparator.INSTANCE); + } + + /** + * Return a custom-filtered {@link Stream} over all matching object instances, + * without specific ordering guarantees (but typically in registration order). + * @param customFilter a custom type filter for selecting beans among the raw + * bean type matches (or {@link #UNFILTERED} for all raw type matches without + * any default filtering) + * @since 6.2.3 + * @see #stream() + * @see #orderedStream(Predicate) + */ + default Stream stream(Predicate> customFilter) { + return stream().filter(obj -> customFilter.test(obj.getClass())); + } + + /** + * Return a custom-filtered {@link Stream} over all matching object instances, + * pre-ordered according to the factory's common order comparator. + * @param customFilter a custom type filter for selecting beans among the raw + * bean type matches (or {@link #UNFILTERED} for all raw type matches without + * any default filtering) + * @since 6.2.3 + * @see #orderedStream() + * @see #stream(Predicate) + */ + default Stream orderedStream(Predicate> customFilter) { + return orderedStream().filter(obj -> customFilter.test(obj.getClass())); } } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/SmartInitializingSingleton.java b/spring-beans/src/main/java/org/springframework/beans/factory/SmartInitializingSingleton.java index 3df636346b9c..fa26c908f18c 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/SmartInitializingSingleton.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/SmartInitializingSingleton.java @@ -21,7 +21,7 @@ * during {@link BeanFactory} bootstrap. This interface can be implemented by * singleton beans in order to perform some initialization after the regular * singleton instantiation algorithm, avoiding side effects with accidental early - * initialization (e.g. from {@link ListableBeanFactory#getBeansOfType} calls). + * initialization (for example, from {@link ListableBeanFactory#getBeansOfType} calls). * In that sense, it is an alternative to {@link InitializingBean} which gets * triggered right at the end of a bean's local construction phase. * diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/Autowired.java b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/Autowired.java index 0fdc535ec4b2..0ba5e9f79890 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/Autowired.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/Autowired.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,18 +54,17 @@ * *

Autowired Parameters

*

Although {@code @Autowired} can technically be declared on individual method - * or constructor parameters since Spring Framework 5.0, most parts of the - * framework ignore such declarations. The only part of the core Spring Framework - * that actively supports autowired parameters is the JUnit Jupiter support in - * the {@code spring-test} module (see the + * or constructor parameters, most parts of the framework ignore such declarations. + * The only part of the core Spring Framework that actively supports autowired + * parameters is the JUnit Jupiter support in the {@code spring-test} module (see the * TestContext framework * reference documentation for details). * *

Multiple Arguments and 'required' Semantics

*

In the case of a multi-arg constructor or method, the {@link #required} attribute * is applicable to all arguments. Individual parameters may be declared as Java-8 style - * {@link java.util.Optional} or, as of Spring Framework 5.0, also as {@code @Nullable} - * or a not-null parameter type in Kotlin, overriding the base 'required' semantics. + * {@link java.util.Optional} as well as {@code @Nullable} or a not-null parameter + * type in Kotlin, overriding the base 'required' semantics. * *

Autowiring Arrays, Collections, and Maps

*

In case of an array, {@link java.util.Collection}, or {@link java.util.Map} diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/AutowiredAnnotationBeanPostProcessor.java b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/AutowiredAnnotationBeanPostProcessor.java index cbfe62cdafbb..f711222c0f11 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/AutowiredAnnotationBeanPostProcessor.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/AutowiredAnnotationBeanPostProcessor.java @@ -29,7 +29,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.Collections; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; @@ -90,6 +89,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; @@ -165,7 +165,7 @@ public class AutowiredAnnotationBeanPostProcessor implements SmartInstantiationA protected final Log logger = LogFactory.getLog(getClass()); - private final Set> autowiredAnnotationTypes = new LinkedHashSet<>(4); + private final Set> autowiredAnnotationTypes = CollectionUtils.newLinkedHashSet(4); private String requiredParameterName = "required"; @@ -179,7 +179,7 @@ public class AutowiredAnnotationBeanPostProcessor implements SmartInstantiationA @Nullable private MetadataReaderFactory metadataReaderFactory; - private final Set lookupMethodsChecked = Collections.newSetFromMap(new ConcurrentHashMap<>(256)); + private final Set lookupMethodsChecked = ConcurrentHashMap.newKeySet(256); private final Map, Constructor[]> candidateConstructorsCache = new ConcurrentHashMap<>(256); @@ -876,7 +876,7 @@ private Object[] resolveMethodArguments(Method method, Object bean, @Nullable St int argumentCount = method.getParameterCount(); Object[] arguments = new Object[argumentCount]; DependencyDescriptor[] descriptors = new DependencyDescriptor[argumentCount]; - Set autowiredBeanNames = new LinkedHashSet<>(argumentCount * 2); + Set autowiredBeanNames = CollectionUtils.newLinkedHashSet(argumentCount); Assert.state(beanFactory != null, "No BeanFactory available"); TypeConverter typeConverter = beanFactory.getTypeConverter(); for (int i = 0; i < arguments.length; i++) { diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/BeanFactoryAnnotationUtils.java b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/BeanFactoryAnnotationUtils.java index fd41a5cfe0a1..0e1ecd046ff3 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/BeanFactoryAnnotationUtils.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/BeanFactoryAnnotationUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 the original author 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.beans.factory.annotation; +import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; import java.util.LinkedHashMap; import java.util.Map; @@ -49,7 +50,7 @@ public abstract class BeanFactoryAnnotationUtils { /** * Retrieve all beans of type {@code T} from the given {@code BeanFactory} declaring a - * qualifier (e.g. via {@code } or {@code @Qualifier}) matching the given + * qualifier (for example, via {@code } or {@code @Qualifier}) matching the given * qualifier, or having a bean name matching the given qualifier. * @param beanFactory the factory to get the target beans from (also searching ancestors) * @param beanType the type of beans to retrieve @@ -74,7 +75,7 @@ public static Map qualifiedBeansOfType( /** * Obtain a bean of type {@code T} from the given {@code BeanFactory} declaring a - * qualifier (e.g. via {@code } or {@code @Qualifier}) matching the given + * qualifier (for example, via {@code } or {@code @Qualifier}) matching the given * qualifier, or having a bean name matching the given qualifier. * @param beanFactory the factory to get the target bean from (also searching ancestors) * @param beanType the type of bean to retrieve @@ -94,7 +95,7 @@ public static T qualifiedBeanOfType(BeanFactory beanFactory, Class beanTy // Full qualifier matching supported. return qualifiedBeanOfType(lbf, beanType, qualifier); } - else if (beanFactory.containsBean(qualifier)) { + else if (beanFactory.containsBean(qualifier) && beanFactory.isTypeMatch(qualifier, beanType)) { // Fallback: target bean at least found by bean name. return beanFactory.getBean(qualifier, beanType); } @@ -108,17 +109,17 @@ else if (beanFactory.containsBean(qualifier)) { /** * Obtain a bean of type {@code T} from the given {@code BeanFactory} declaring a qualifier - * (e.g. {@code } or {@code @Qualifier}) matching the given qualifier). - * @param bf the factory to get the target bean from + * (for example, {@code } or {@code @Qualifier}) matching the given qualifier). + * @param beanFactory the factory to get the target bean from * @param beanType the type of bean to retrieve * @param qualifier the qualifier for selecting between multiple bean matches * @return the matching bean of type {@code T} (never {@code null}) */ - private static T qualifiedBeanOfType(ListableBeanFactory bf, Class beanType, String qualifier) { - String[] candidateBeans = BeanFactoryUtils.beanNamesForTypeIncludingAncestors(bf, beanType); + private static T qualifiedBeanOfType(ListableBeanFactory beanFactory, Class beanType, String qualifier) { + String[] candidateBeans = BeanFactoryUtils.beanNamesForTypeIncludingAncestors(beanFactory, beanType); String matchingBean = null; for (String beanName : candidateBeans) { - if (isQualifierMatch(qualifier::equals, beanName, bf)) { + if (isQualifierMatch(qualifier::equals, beanName, beanFactory)) { if (matchingBean != null) { throw new NoUniqueBeanDefinitionException(beanType, matchingBean, beanName); } @@ -126,11 +127,11 @@ private static T qualifiedBeanOfType(ListableBeanFactory bf, Class beanTy } } if (matchingBean != null) { - return bf.getBean(matchingBean, beanType); + return beanFactory.getBean(matchingBean, beanType); } - else if (bf.containsBean(qualifier)) { + else if (beanFactory.containsBean(qualifier) && beanFactory.isTypeMatch(qualifier, beanType)) { // Fallback: target bean at least found by bean name - probably a manually registered singleton. - return bf.getBean(qualifier, beanType); + return beanFactory.getBean(qualifier, beanType); } else { throw new NoSuchBeanDefinitionException(qualifier, "No matching " + beanType.getSimpleName() + @@ -138,6 +139,19 @@ else if (bf.containsBean(qualifier)) { } } + /** + * Determine the {@link Qualifier#value() qualifier value} for the given + * annotated element. + * @param annotatedElement the class, method or parameter to introspect + * @return the associated qualifier value, or {@code null} if none + * @since 6.2 + */ + @Nullable + public static String getQualifierValue(AnnotatedElement annotatedElement) { + Qualifier qualifier = AnnotationUtils.getAnnotation(annotatedElement, Qualifier.class); + return (qualifier != null ? qualifier.value() : null); + } + /** * Check whether the named bean declares a qualifier of the given name. * @param qualifier the qualifier to match diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/InitDestroyAnnotationBeanPostProcessor.java b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/InitDestroyAnnotationBeanPostProcessor.java index 708064488acf..085fe95b0185 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/InitDestroyAnnotationBeanPostProcessor.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/InitDestroyAnnotationBeanPostProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -363,7 +363,7 @@ public LifecycleMetadata(Class beanClass, Collection initMet } public void checkInitDestroyMethods(RootBeanDefinition beanDefinition) { - Set checkedInitMethods = new LinkedHashSet<>(this.initMethods.size()); + Set checkedInitMethods = CollectionUtils.newLinkedHashSet(this.initMethods.size()); for (LifecycleMethod lifecycleMethod : this.initMethods) { String methodIdentifier = lifecycleMethod.getIdentifier(); if (!beanDefinition.isExternallyManagedInitMethod(methodIdentifier)) { @@ -374,7 +374,7 @@ public void checkInitDestroyMethods(RootBeanDefinition beanDefinition) { } } } - Set checkedDestroyMethods = new LinkedHashSet<>(this.destroyMethods.size()); + Set checkedDestroyMethods = CollectionUtils.newLinkedHashSet(this.destroyMethods.size()); for (LifecycleMethod lifecycleMethod : this.destroyMethods) { String methodIdentifier = lifecycleMethod.getIdentifier(); if (!beanDefinition.isExternallyManagedDestroyMethod(methodIdentifier)) { diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/InjectionMetadata.java b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/InjectionMetadata.java index d1a5946aa0fb..bdd4e4d6a962 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/InjectionMetadata.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/InjectionMetadata.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,13 +23,14 @@ import java.lang.reflect.Method; import java.util.Collection; import java.util.Collections; -import java.util.LinkedHashSet; import java.util.Set; import org.springframework.beans.MutablePropertyValues; import org.springframework.beans.PropertyValues; import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.lang.Contract; import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; import org.springframework.util.ReflectionUtils; /** @@ -124,7 +125,7 @@ public void checkConfigMembers(RootBeanDefinition beanDefinition) { this.checkedElements = Collections.emptySet(); } else { - Set checkedElements = new LinkedHashSet<>((this.injectedElements.size() * 4 / 3) + 1); + Set checkedElements = CollectionUtils.newLinkedHashSet(this.injectedElements.size()); for (InjectedElement element : this.injectedElements) { Member member = element.getMember(); if (!beanDefinition.isExternallyManagedConfigMember(member)) { @@ -182,6 +183,7 @@ public static InjectionMetadata forElements(Collection elements * @return {@code true} indicating a refresh, {@code false} otherwise * @see #needsRefresh(Class) */ + @Contract("null, _ -> true") public static boolean needsRefresh(@Nullable InjectionMetadata metadata, Class clazz) { return (metadata == null || metadata.needsRefresh(clazz)); } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/ParameterResolutionDelegate.java b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/ParameterResolutionDelegate.java index f8f7b0dce6f4..a9dee0daa727 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/ParameterResolutionDelegate.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/ParameterResolutionDelegate.java @@ -153,7 +153,7 @@ public static Object resolveDependency( * an empty {@code AnnotatedElement}. *

WARNING

*

The {@code AnnotatedElement} returned by this method should never be cast and - * treated as a {@code Parameter} since the metadata (e.g., {@link Parameter#getName()}, + * treated as a {@code Parameter} since the metadata (for example, {@link Parameter#getName()}, * {@link Parameter#getType()}, etc.) will not match those for the declared parameter * at the given index in an inner class constructor. * @return the supplied {@code parameter} or the effective {@code Parameter} diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/QualifierAnnotationAutowireCandidateResolver.java b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/QualifierAnnotationAutowireCandidateResolver.java index 47d5ad1c8d87..4281e6539497 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/annotation/QualifierAnnotationAutowireCandidateResolver.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/annotation/QualifierAnnotationAutowireCandidateResolver.java @@ -19,7 +19,6 @@ import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; -import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; @@ -40,6 +39,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; /** @@ -61,7 +61,7 @@ */ public class QualifierAnnotationAutowireCandidateResolver extends GenericTypeAwareAutowireCandidateResolver { - private final Set> qualifierTypes = new LinkedHashSet<>(2); + private final Set> qualifierTypes = CollectionUtils.newLinkedHashSet(2); private Class valueAnnotationType = Value.class; @@ -154,68 +154,79 @@ public void setValueAnnotationType(Class valueAnnotationTy */ @Override public boolean isAutowireCandidate(BeanDefinitionHolder bdHolder, DependencyDescriptor descriptor) { - boolean match = super.isAutowireCandidate(bdHolder, descriptor); - if (match) { - match = checkQualifiers(bdHolder, descriptor.getAnnotations()); - if (match) { - MethodParameter methodParam = descriptor.getMethodParameter(); - if (methodParam != null) { - Method method = methodParam.getMethod(); - if (method == null || void.class == method.getReturnType()) { - match = checkQualifiers(bdHolder, methodParam.getMethodAnnotations()); + if (!super.isAutowireCandidate(bdHolder, descriptor)) { + return false; + } + Boolean checked = checkQualifiers(bdHolder, descriptor.getAnnotations()); + if (checked != Boolean.FALSE) { + MethodParameter methodParam = descriptor.getMethodParameter(); + if (methodParam != null) { + Method method = methodParam.getMethod(); + if (method == null || void.class == method.getReturnType()) { + Boolean methodChecked = checkQualifiers(bdHolder, methodParam.getMethodAnnotations()); + if (methodChecked != null && checked == null) { + checked = methodChecked; } } } } - return match; + return (checked == Boolean.TRUE || + (checked == null && ((RootBeanDefinition) bdHolder.getBeanDefinition()).isDefaultCandidate())); } /** * Match the given qualifier annotations against the candidate bean definition. + * @return {@code false} if a qualifier has been found but not matched, + * {@code true} if a qualifier has been found and matched, + * {@code null} if no qualifier has been found at all */ - protected boolean checkQualifiers(BeanDefinitionHolder bdHolder, Annotation[] annotationsToSearch) { - if (ObjectUtils.isEmpty(annotationsToSearch)) { - return true; - } - SimpleTypeConverter typeConverter = new SimpleTypeConverter(); - for (Annotation annotation : annotationsToSearch) { - Class type = annotation.annotationType(); - if (isPlainJavaAnnotation(type)) { - continue; - } - boolean checkMeta = true; - boolean fallbackToMeta = false; - if (isQualifier(type)) { - if (!checkQualifier(bdHolder, annotation, typeConverter)) { - fallbackToMeta = true; - } - else { - checkMeta = false; + + @Nullable + protected Boolean checkQualifiers(BeanDefinitionHolder bdHolder, Annotation[] annotationsToSearch) { + boolean qualifierFound = false; + if (!ObjectUtils.isEmpty(annotationsToSearch)) { + SimpleTypeConverter typeConverter = new SimpleTypeConverter(); + for (Annotation annotation : annotationsToSearch) { + Class type = annotation.annotationType(); + if (isPlainJavaAnnotation(type)) { + continue; } - } - if (checkMeta) { - boolean foundMeta = false; - for (Annotation metaAnn : type.getAnnotations()) { - Class metaType = metaAnn.annotationType(); - if (isPlainJavaAnnotation(metaType)) { - continue; + boolean checkMeta = true; + boolean fallbackToMeta = false; + if (isQualifier(type)) { + qualifierFound = true; + if (!checkQualifier(bdHolder, annotation, typeConverter)) { + fallbackToMeta = true; } - if (isQualifier(metaType)) { - foundMeta = true; - // Only accept fallback match if @Qualifier annotation has a value... - // Otherwise, it is just a marker for a custom qualifier annotation. - if ((fallbackToMeta && ObjectUtils.isEmpty(AnnotationUtils.getValue(metaAnn))) || - !checkQualifier(bdHolder, metaAnn, typeConverter)) { - return false; - } + else { + checkMeta = false; } } - if (fallbackToMeta && !foundMeta) { - return false; + if (checkMeta) { + boolean foundMeta = false; + for (Annotation metaAnn : type.getAnnotations()) { + Class metaType = metaAnn.annotationType(); + if (isPlainJavaAnnotation(metaType)) { + continue; + } + if (isQualifier(metaType)) { + qualifierFound = true; + foundMeta = true; + // Only accept fallback match if @Qualifier annotation has a value... + // Otherwise, it is just a marker for a custom qualifier annotation. + if ((fallbackToMeta && ObjectUtils.isEmpty(AnnotationUtils.getValue(metaAnn))) || + !checkQualifier(bdHolder, metaAnn, typeConverter)) { + return false; + } + } + } + if (fallbackToMeta && !foundMeta) { + return false; + } } } } - return true; + return (qualifierFound ? true : null); } /** @@ -367,6 +378,20 @@ public boolean hasQualifier(DependencyDescriptor descriptor) { return false; } + @Override + @Nullable + public String getSuggestedName(DependencyDescriptor descriptor) { + for (Annotation annotation : descriptor.getAnnotations()) { + if (isQualifier(annotation.annotationType())) { + Object value = AnnotationUtils.getValue(annotation); + if (value instanceof String str) { + return str; + } + } + } + return null; + } + /** * Determine whether the given dependency declares a value annotation. * @see Value diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/AotBeanProcessingException.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/AotBeanProcessingException.java new file mode 100644 index 000000000000..03173bf9a9f7 --- /dev/null +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/AotBeanProcessingException.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.beans.factory.aot; + +import org.springframework.beans.factory.support.RegisteredBean; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.lang.Nullable; + +/** + * Thrown when AOT fails to process a bean. + * + * @author Stephane Nicoll + * @since 6.2 + */ +@SuppressWarnings("serial") +public class AotBeanProcessingException extends AotProcessingException { + + private final RootBeanDefinition beanDefinition; + + /** + * Create an instance with the {@link RegisteredBean} that fails to be + * processed, a detail message, and an optional root cause. + * @param registeredBean the registered bean that fails to be processed + * @param msg the detail message + * @param cause the root cause, if any + */ + public AotBeanProcessingException(RegisteredBean registeredBean, String msg, @Nullable Throwable cause) { + super(createErrorMessage(registeredBean, msg), cause); + this.beanDefinition = registeredBean.getMergedBeanDefinition(); + } + + /** + * Shortcut to create an instance with the {@link RegisteredBean} that fails + * to be processed with only a detail message. + * @param registeredBean the registered bean that fails to be processed + * @param msg the detail message + */ + public AotBeanProcessingException(RegisteredBean registeredBean, String msg) { + this(registeredBean, msg, null); + } + + private static String createErrorMessage(RegisteredBean registeredBean, String msg) { + StringBuilder sb = new StringBuilder("Error processing bean with name '"); + sb.append(registeredBean.getBeanName()).append("'"); + String resourceDescription = registeredBean.getMergedBeanDefinition().getResourceDescription(); + if (resourceDescription != null) { + sb.append(" defined in ").append(resourceDescription); + } + sb.append(": ").append(msg); + return sb.toString(); + } + + /** + * Return the bean definition of the bean that failed to be processed. + */ + public RootBeanDefinition getBeanDefinition() { + return this.beanDefinition; + } + +} diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/AotException.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/AotException.java new file mode 100644 index 000000000000..0b1f810726e5 --- /dev/null +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/AotException.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.beans.factory.aot; + +import org.springframework.lang.Nullable; + +/** + * Abstract superclass for all exceptions thrown by ahead-of-time processing. + * + * @author Stephane Nicoll + * @since 6.2 + */ +@SuppressWarnings("serial") +public abstract class AotException extends RuntimeException { + + /** + * Create an instance with the specified message and root cause. + * @param msg the detail message + * @param cause the root cause + */ + protected AotException(@Nullable String msg, @Nullable Throwable cause) { + super(msg, cause); + } + +} diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/AotProcessingException.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/AotProcessingException.java new file mode 100644 index 000000000000..87613d63dafb --- /dev/null +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/AotProcessingException.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.beans.factory.aot; + +import org.springframework.lang.Nullable; + +/** + * Throw when an AOT processor failed. + * + * @author Stephane Nicoll + * @since 6.2 + */ +@SuppressWarnings("serial") +public class AotProcessingException extends AotException { + + /** + * Create a new instance with the detail message and a root cause, if any. + * @param msg the detail message + * @param cause the root cause, if any + */ + public AotProcessingException(String msg, @Nullable Throwable cause) { + super(msg, cause); + } + +} diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/AutowiredArgumentsCodeGenerator.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/AutowiredArgumentsCodeGenerator.java index 8a8f152cd7ca..0afc2419a808 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/AutowiredArgumentsCodeGenerator.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/AutowiredArgumentsCodeGenerator.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,21 +60,18 @@ public CodeBlock generateCode(Class[] parameterTypes, int startIndex) { return generateCode(parameterTypes, startIndex, "args"); } - public CodeBlock generateCode(Class[] parameterTypes, int startIndex, - String variableName) { - + public CodeBlock generateCode(Class[] parameterTypes, int startIndex, String variableName) { Assert.notNull(parameterTypes, "'parameterTypes' must not be null"); Assert.notNull(variableName, "'variableName' must not be null"); boolean ambiguous = isAmbiguous(); CodeBlock.Builder code = CodeBlock.builder(); for (int i = startIndex; i < parameterTypes.length; i++) { - code.add((i != startIndex) ? ", " : ""); + code.add(i > startIndex ? ", " : ""); if (!ambiguous) { code.add("$L.get($L)", variableName, i - startIndex); } else { - code.add("$L.get($L, $T.class)", variableName, i - startIndex, - parameterTypes[i]); + code.add("$L.get($L, $T.class)", variableName, i - startIndex, parameterTypes[i]); } } return code.build(); diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/AutowiredFieldValueResolver.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/AutowiredFieldValueResolver.java index 1c5f68fe902f..ca3a30e071ba 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/AutowiredFieldValueResolver.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/AutowiredFieldValueResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,16 +58,14 @@ public final class AutowiredFieldValueResolver extends AutowiredElementResolver private final boolean required; @Nullable - private final String shortcut; + private final String shortcutBeanName; - private AutowiredFieldValueResolver(String fieldName, boolean required, - @Nullable String shortcut) { - + private AutowiredFieldValueResolver(String fieldName, boolean required, @Nullable String shortcut) { Assert.hasText(fieldName, "'fieldName' must not be empty"); this.fieldName = fieldName; this.required = required; - this.shortcut = shortcut; + this.shortcutBeanName = shortcut; } @@ -97,7 +95,7 @@ public static AutowiredFieldValueResolver forRequiredField(String fieldName) { * direct bean name injection shortcut. * @param beanName the bean name to use as a shortcut * @return a new {@link AutowiredFieldValueResolver} instance that uses the - * shortcuts + * given shortcut bean name */ public AutowiredFieldValueResolver withShortcut(String beanName) { return new AutowiredFieldValueResolver(this.fieldName, this.required, beanName); @@ -178,8 +176,8 @@ private Object resolveValue(RegisteredBean registeredBean, Field field) { ConfigurableBeanFactory beanFactory = registeredBean.getBeanFactory(); DependencyDescriptor descriptor = new DependencyDescriptor(field, this.required); descriptor.setContainingClass(beanClass); - if (this.shortcut != null) { - descriptor = new ShortcutDependencyDescriptor(descriptor, this.shortcut); + if (this.shortcutBeanName != null) { + descriptor = new ShortcutDependencyDescriptor(descriptor, this.shortcutBeanName); } Set autowiredBeanNames = new LinkedHashSet<>(1); TypeConverter typeConverter = beanFactory.getTypeConverter(); diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/AutowiredMethodArgumentsResolver.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/AutowiredMethodArgumentsResolver.java index 9c79f232114a..e52930dc3de7 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/AutowiredMethodArgumentsResolver.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/AutowiredMethodArgumentsResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.lang.reflect.Method; import java.util.Arrays; -import java.util.LinkedHashSet; import java.util.Set; import java.util.stream.Collectors; @@ -34,6 +33,7 @@ import org.springframework.core.MethodParameter; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.ReflectionUtils; import org.springframework.util.function.ThrowingConsumer; @@ -63,17 +63,17 @@ public final class AutowiredMethodArgumentsResolver extends AutowiredElementReso private final boolean required; @Nullable - private final String[] shortcuts; + private final String[] shortcutBeanNames; private AutowiredMethodArgumentsResolver(String methodName, Class[] parameterTypes, - boolean required, @Nullable String[] shortcuts) { + boolean required, @Nullable String[] shortcutBeanNames) { Assert.hasText(methodName, "'methodName' must not be empty"); this.methodName = methodName; this.parameterTypes = parameterTypes; this.required = required; - this.shortcuts = shortcuts; + this.shortcutBeanNames = shortcutBeanNames; } @@ -105,7 +105,7 @@ public static AutowiredMethodArgumentsResolver forRequiredMethod(String methodNa * @param beanNames the bean names to use as shortcuts (aligned with the * method parameters) * @return a new {@link AutowiredMethodArgumentsResolver} instance that uses - * the shortcuts + * the given shortcut bean names */ public AutowiredMethodArgumentsResolver withShortcut(String... beanNames) { return new AutowiredMethodArgumentsResolver(this.methodName, this.parameterTypes, this.required, beanNames); @@ -165,13 +165,13 @@ private AutowiredArguments resolveArguments(RegisteredBean registeredBean, AutowireCapableBeanFactory autowireCapableBeanFactory = (AutowireCapableBeanFactory) beanFactory; int argumentCount = method.getParameterCount(); Object[] arguments = new Object[argumentCount]; - Set autowiredBeanNames = new LinkedHashSet<>(argumentCount); + Set autowiredBeanNames = CollectionUtils.newLinkedHashSet(argumentCount); TypeConverter typeConverter = beanFactory.getTypeConverter(); for (int i = 0; i < argumentCount; i++) { MethodParameter parameter = new MethodParameter(method, i); DependencyDescriptor descriptor = new DependencyDescriptor(parameter, this.required); descriptor.setContainingClass(beanClass); - String shortcut = (this.shortcuts != null ? this.shortcuts[i] : null); + String shortcut = (this.shortcutBeanNames != null ? this.shortcutBeanNames[i] : null); if (shortcut != null) { descriptor = new ShortcutDependencyDescriptor(descriptor, shortcut); } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanDefinitionMethodGeneratorFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanDefinitionMethodGeneratorFactory.java index 580a0533cedb..6938f0c9ef2b 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanDefinitionMethodGeneratorFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanDefinitionMethodGeneratorFactory.java @@ -133,6 +133,10 @@ private boolean isExcluded(RegisteredBean registeredBean) { } private boolean isImplicitlyExcluded(RegisteredBean registeredBean) { + if (Boolean.TRUE.equals(registeredBean.getMergedBeanDefinition() + .getAttribute(BeanRegistrationAotProcessor.IGNORE_REGISTRATION_ATTRIBUTE))) { + return true; + } Class beanClass = registeredBean.getBeanClass(); if (BeanFactoryInitializationAotProcessor.class.isAssignableFrom(beanClass)) { return true; diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanDefinitionPropertiesCodeGenerator.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanDefinitionPropertiesCodeGenerator.java index f183d2b05e14..3fb42523b144 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanDefinitionPropertiesCodeGenerator.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanDefinitionPropertiesCodeGenerator.java @@ -113,24 +113,34 @@ class BeanDefinitionPropertiesCodeGenerator { CodeBlock generateCode(RootBeanDefinition beanDefinition) { CodeBlock.Builder code = CodeBlock.builder(); - addStatementForValue(code, beanDefinition, BeanDefinition::isPrimary, - "$L.setPrimary($L)"); addStatementForValue(code, beanDefinition, BeanDefinition::getScope, this::hasScope, "$L.setScope($S)"); + addStatementForValue(code, beanDefinition, AbstractBeanDefinition::isBackgroundInit, + "$L.setBackgroundInit($L)"); + addStatementForValue(code, beanDefinition, AbstractBeanDefinition::getLazyInit, + "$L.setLazyInit($L)"); addStatementForValue(code, beanDefinition, BeanDefinition::getDependsOn, this::hasDependsOn, "$L.setDependsOn($L)", this::toStringVarArgs); addStatementForValue(code, beanDefinition, BeanDefinition::isAutowireCandidate, "$L.setAutowireCandidate($L)"); - addStatementForValue(code, beanDefinition, BeanDefinition::getRole, - this::hasRole, "$L.setRole($L)", this::toRole); - addStatementForValue(code, beanDefinition, AbstractBeanDefinition::getLazyInit, - "$L.setLazyInit($L)"); + addStatementForValue(code, beanDefinition, AbstractBeanDefinition::isDefaultCandidate, + "$L.setDefaultCandidate($L)"); + addStatementForValue(code, beanDefinition, BeanDefinition::isPrimary, + "$L.setPrimary($L)"); + addStatementForValue(code, beanDefinition, BeanDefinition::isFallback, + "$L.setFallback($L)"); addStatementForValue(code, beanDefinition, AbstractBeanDefinition::isSynthetic, "$L.setSynthetic($L)"); + addStatementForValue(code, beanDefinition, BeanDefinition::getRole, + this::hasRole, "$L.setRole($L)", this::toRole); addInitDestroyMethods(code, beanDefinition, beanDefinition.getInitMethodNames(), "$L.setInitMethodNames($L)"); addInitDestroyMethods(code, beanDefinition, beanDefinition.getDestroyMethodNames(), "$L.setDestroyMethodNames($L)"); + if (beanDefinition.getFactoryBeanName() != null) { + addStatementForValue(code, beanDefinition, BeanDefinition::getFactoryBeanName, + "$L.setFactoryBeanName(\"$L\")"); + } addConstructorArgumentValues(code, beanDefinition); addPropertyValues(code, beanDefinition); addAttributes(code, beanDefinition); @@ -140,6 +150,7 @@ CodeBlock generateCode(RootBeanDefinition beanDefinition) { private void addInitDestroyMethods(Builder code, AbstractBeanDefinition beanDefinition, @Nullable String[] methodNames, String format) { + // For Publisher-based destroy methods this.hints.reflection().registerType(TypeReference.of("org.reactivestreams.Publisher")); if (!ObjectUtils.isEmpty(methodNames)) { @@ -174,9 +185,9 @@ private void addInitDestroyHint(Class beanUserClass, String methodName) { Method method = ReflectionUtils.findMethod(methodDeclaringClass, methodName); if (method != null) { this.hints.reflection().registerMethod(method, ExecutableMode.INVOKE); - Method interfaceMethod = ClassUtils.getInterfaceMethodIfPossible(method, beanUserClass); - if (!interfaceMethod.equals(method)) { - this.hints.reflection().registerMethod(interfaceMethod, ExecutableMode.INVOKE); + Method publiclyAccessibleMethod = ClassUtils.getPubliclyAccessibleMethodIfPossible(method, beanUserClass); + if (!publiclyAccessibleMethod.equals(method)) { + this.hints.reflection().registerMethod(publiclyAccessibleMethod, ExecutableMode.INVOKE); } } } @@ -208,7 +219,6 @@ private void addConstructorArgumentValues(CodeBlock.Builder code, BeanDefinition else if (valueHolder.getType() != null) { code.addStatement("$L.getConstructorArgumentValues().addGenericArgumentValue($L, $S)", BEAN_DEFINITION_VARIABLE, valueCode, valueHolder.getType()); - } else { code.addStatement("$L.getConstructorArgumentValues().addGenericArgumentValue($L)", @@ -222,7 +232,8 @@ private void addPropertyValues(CodeBlock.Builder code, RootBeanDefinition beanDe MutablePropertyValues propertyValues = beanDefinition.getPropertyValues(); if (!propertyValues.isEmpty()) { Class infrastructureType = getInfrastructureType(beanDefinition); - Map writeMethods = (infrastructureType != Object.class) ? getWriteMethods(infrastructureType) : Collections.emptyMap(); + Map writeMethods = (infrastructureType != Object.class ? + getWriteMethods(infrastructureType) : Collections.emptyMap()); for (PropertyValue propertyValue : propertyValues) { String name = propertyValue.getName(); CodeBlock valueCode = generateValue(name, propertyValue.getValue()); @@ -264,8 +275,8 @@ private void addQualifiers(CodeBlock.Builder code, RootBeanDefinition beanDefini } private CodeBlock generateValue(@Nullable String name, @Nullable Object value) { + PropertyNamesStack.push(name); try { - PropertyNamesStack.push(name); return this.valueCodeGenerator.generateCode(value); } finally { @@ -306,8 +317,7 @@ private void addAttributes(CodeBlock.Builder code, BeanDefinition beanDefinition } private boolean hasScope(String defaultValue, String actualValue) { - return StringUtils.hasText(actualValue) && - !ConfigurableBeanFactory.SCOPE_SINGLETON.equals(actualValue); + return (StringUtils.hasText(actualValue) && !ConfigurableBeanFactory.SCOPE_SINGLETON.equals(actualValue)); } private boolean hasDependsOn(String[] defaultValue, String[] actualValue) { @@ -333,8 +343,7 @@ private Object toRole(int value) { } private void addStatementForValue( - CodeBlock.Builder code, BeanDefinition beanDefinition, - Function getter, String format) { + CodeBlock.Builder code, BeanDefinition beanDefinition, Function getter, String format) { addStatementForValue(code, beanDefinition, getter, (defaultValue, actualValue) -> !Objects.equals(defaultValue, actualValue), format); @@ -349,9 +358,8 @@ private void addStatementForValue( @SuppressWarnings("unchecked") private void addStatementForValue( - CodeBlock.Builder code, BeanDefinition beanDefinition, - Function getter, BiPredicate filter, String format, - Function formatter) { + CodeBlock.Builder code, BeanDefinition beanDefinition, Function getter, + BiPredicate filter, String format, Function formatter) { T defaultValue = getter.apply((B) DEFAULT_BEAN_DEFINITION); T actualValue = getter.apply((B) beanDefinition); @@ -361,9 +369,8 @@ private void addStatementForValue( } /** - * Cast the specified {@code valueCode} to the specified {@code castType} if - * the {@code castNecessary} is {@code true}. Otherwise return the valueCode - * as is. + * Cast the specified {@code valueCode} to the specified {@code castType} if the + * {@code castNecessary} is {@code true}. Otherwise, return the valueCode as-is. * @param castNecessary whether a cast is necessary * @param castType the type to cast to * @param valueCode the code for the value diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanDefinitionPropertyValueCodeGeneratorDelegates.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanDefinitionPropertyValueCodeGeneratorDelegates.java index c4188077839a..1b9f1fcc8ad5 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanDefinitionPropertyValueCodeGeneratorDelegates.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanDefinitionPropertyValueCodeGeneratorDelegates.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,15 +48,15 @@ abstract class BeanDefinitionPropertyValueCodeGeneratorDelegates { /** - * Return the {@link Delegate} implementations for common bean definition - * property value types. These are: + * A list of {@link Delegate} implementations for the following common bean + * definition property value types. *

    - *
  • {@link ManagedList},
  • - *
  • {@link ManagedSet},
  • - *
  • {@link ManagedMap},
  • - *
  • {@link LinkedHashMap},
  • - *
  • {@link BeanReference},
  • - *
  • {@link TypedStringValue}.
  • + *
  • {@link ManagedList}
  • + *
  • {@link ManagedSet}
  • + *
  • {@link ManagedMap}
  • + *
  • {@link LinkedHashMap}
  • + *
  • {@link BeanReference}
  • + *
  • {@link TypedStringValue}
  • *
* When combined with {@linkplain ValueCodeGeneratorDelegates#INSTANCES the * delegates for common value types}, this should be added first as they have diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanInstanceSupplier.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanInstanceSupplier.java index a1992cb88760..4d6eecec4e36 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanInstanceSupplier.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanInstanceSupplier.java @@ -22,7 +22,6 @@ import java.lang.reflect.Modifier; import java.lang.reflect.Parameter; import java.util.Arrays; -import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; @@ -33,9 +32,7 @@ import org.springframework.beans.BeanUtils; import org.springframework.beans.BeansException; import org.springframework.beans.TypeConverter; -import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.UnsatisfiedDependencyException; -import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.beans.factory.config.ConstructorArgumentValues; import org.springframework.beans.factory.config.ConstructorArgumentValues.ValueHolder; import org.springframework.beans.factory.config.DependencyDescriptor; @@ -49,7 +46,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; -import org.springframework.util.ObjectUtils; +import org.springframework.util.CollectionUtils; import org.springframework.util.ReflectionUtils; import org.springframework.util.function.ThrowingBiFunction; import org.springframework.util.function.ThrowingFunction; @@ -79,6 +76,7 @@ * * @author Phillip Webb * @author Stephane Nicoll + * @author Juergen Hoeller * @since 6.0 * @param the type of instance supplied by this supplier * @see AutowiredArguments @@ -88,19 +86,24 @@ public final class BeanInstanceSupplier extends AutowiredElementResolver impl private final ExecutableLookup lookup; @Nullable - private final ThrowingBiFunction generator; + private final ThrowingFunction generatorWithoutArguments; @Nullable - private final String[] shortcuts; + private final ThrowingBiFunction generatorWithArguments; + + @Nullable + private final String[] shortcutBeanNames; private BeanInstanceSupplier(ExecutableLookup lookup, - @Nullable ThrowingBiFunction generator, - @Nullable String[] shortcuts) { + @Nullable ThrowingFunction generatorWithoutArguments, + @Nullable ThrowingBiFunction generatorWithArguments, + @Nullable String[] shortcutBeanNames) { this.lookup = lookup; - this.generator = generator; - this.shortcuts = shortcuts; + this.generatorWithoutArguments = generatorWithoutArguments; + this.generatorWithArguments = generatorWithArguments; + this.shortcutBeanNames = shortcutBeanNames; } @@ -114,7 +117,7 @@ private BeanInstanceSupplier(ExecutableLookup lookup, public static BeanInstanceSupplier forConstructor(Class... parameterTypes) { Assert.notNull(parameterTypes, "'parameterTypes' must not be null"); Assert.noNullElements(parameterTypes, "'parameterTypes' must not contain null elements"); - return new BeanInstanceSupplier<>(new ConstructorLookup(parameterTypes), null, null); + return new BeanInstanceSupplier<>(new ConstructorLookup(parameterTypes), null, null, null); } /** @@ -135,7 +138,7 @@ public static BeanInstanceSupplier forFactoryMethod( Assert.noNullElements(parameterTypes, "'parameterTypes' must not contain null elements"); return new BeanInstanceSupplier<>( new FactoryMethodLookup(declaringClass, methodName, parameterTypes), - null, null); + null, null, null); } @@ -151,11 +154,9 @@ ExecutableLookup getLookup() { * instantiate the underlying bean * @return a new {@link BeanInstanceSupplier} instance with the specified generator */ - public BeanInstanceSupplier withGenerator( - ThrowingBiFunction generator) { - + public BeanInstanceSupplier withGenerator(ThrowingBiFunction generator) { Assert.notNull(generator, "'generator' must not be null"); - return new BeanInstanceSupplier<>(this.lookup, generator, this.shortcuts); + return new BeanInstanceSupplier<>(this.lookup, null, generator, this.shortcutBeanNames); } /** @@ -167,8 +168,7 @@ public BeanInstanceSupplier withGenerator( */ public BeanInstanceSupplier withGenerator(ThrowingFunction generator) { Assert.notNull(generator, "'generator' must not be null"); - return new BeanInstanceSupplier<>(this.lookup, - (registeredBean, args) -> generator.apply(registeredBean), this.shortcuts); + return new BeanInstanceSupplier<>(this.lookup, generator, null, this.shortcutBeanNames); } /** @@ -181,57 +181,83 @@ public BeanInstanceSupplier withGenerator(ThrowingFunction @Deprecated(since = "6.0.11", forRemoval = true) public BeanInstanceSupplier withGenerator(ThrowingSupplier generator) { Assert.notNull(generator, "'generator' must not be null"); - return new BeanInstanceSupplier<>(this.lookup, - (registeredBean, args) -> generator.get(), this.shortcuts); + return new BeanInstanceSupplier<>(this.lookup, registeredBean -> generator.get(), + null, this.shortcutBeanNames); } /** * Return a new {@link BeanInstanceSupplier} instance * that uses direct bean name injection shortcuts for specific parameters. - * @param beanNames the bean names to use as shortcuts (aligned with the - * constructor or factory method parameters) - * @return a new {@link BeanInstanceSupplier} instance - * that uses the shortcuts + * @deprecated in favor of {@link #withShortcut(String...)} */ + @Deprecated(since = "6.2", forRemoval = true) public BeanInstanceSupplier withShortcuts(String... beanNames) { - return new BeanInstanceSupplier<>(this.lookup, this.generator, beanNames); + return withShortcut(beanNames); } - @Override - public T get(RegisteredBean registeredBean) throws Exception { - Assert.notNull(registeredBean, "'registeredBean' must not be null"); - Executable executable = this.lookup.get(registeredBean); - AutowiredArguments arguments = resolveArguments(registeredBean, executable); - if (this.generator != null) { - return invokeBeanSupplier(executable, () -> this.generator.apply(registeredBean, arguments)); - } - return invokeBeanSupplier(executable, - () -> instantiate(registeredBean.getBeanFactory(), executable, arguments.toArray())); + /** + * Return a new {@link BeanInstanceSupplier} instance that uses + * direct bean name injection shortcuts for specific parameters. + * @param beanNames the bean names to use as shortcut (aligned with the + * constructor or factory method parameters) + * @return a new {@link BeanInstanceSupplier} instance that uses the + * given shortcut bean names + * @since 6.2 + */ + public BeanInstanceSupplier withShortcut(String... beanNames) { + return new BeanInstanceSupplier<>( + this.lookup, this.generatorWithoutArguments, this.generatorWithArguments, beanNames); } - private T invokeBeanSupplier(Executable executable, ThrowingSupplier beanSupplier) { - if (!(executable instanceof Method method)) { - return beanSupplier.get(); + + @SuppressWarnings("unchecked") + @Override + public T get(RegisteredBean registeredBean) { + Assert.notNull(registeredBean, "'registeredBean' must not be null"); + if (this.generatorWithoutArguments != null) { + Executable executable = getFactoryMethodForGenerator(); + return invokeBeanSupplier(executable, () -> this.generatorWithoutArguments.apply(registeredBean)); } - Method priorInvokedFactoryMethod = SimpleInstantiationStrategy.getCurrentlyInvokedFactoryMethod(); - try { - SimpleInstantiationStrategy.setCurrentlyInvokedFactoryMethod(method); - return beanSupplier.get(); + else if (this.generatorWithArguments != null) { + Executable executable = getFactoryMethodForGenerator(); + AutowiredArguments arguments = resolveArguments(registeredBean, + executable != null ? executable : this.lookup.get(registeredBean)); + return invokeBeanSupplier(executable, () -> this.generatorWithArguments.apply(registeredBean, arguments)); } - finally { - SimpleInstantiationStrategy.setCurrentlyInvokedFactoryMethod(priorInvokedFactoryMethod); + else { + Executable executable = this.lookup.get(registeredBean); + Object[] arguments = resolveArguments(registeredBean, executable).toArray(); + return invokeBeanSupplier(executable, () -> (T) instantiate(registeredBean, executable, arguments)); } } - @Nullable @Override + @Nullable public Method getFactoryMethod() { + // Cached factory method retrieval for qualifier introspection etc. if (this.lookup instanceof FactoryMethodLookup factoryMethodLookup) { return factoryMethodLookup.get(); } return null; } + @Nullable + private Method getFactoryMethodForGenerator() { + // Avoid unnecessary currentlyInvokedFactoryMethod exposure outside of full configuration classes. + if (this.lookup instanceof FactoryMethodLookup factoryMethodLookup && + factoryMethodLookup.declaringClass.getName().contains(ClassUtils.CGLIB_CLASS_SEPARATOR)) { + return factoryMethodLookup.get(); + } + return null; + } + + private T invokeBeanSupplier(@Nullable Executable executable, ThrowingSupplier beanSupplier) { + if (executable instanceof Method method) { + return SimpleInstantiationStrategy.instantiateWithFactoryMethod(method, beanSupplier); + } + return beanSupplier.get(); + } + /** * Resolve arguments for the specified registered bean. * @param registeredBean the registered bean @@ -243,26 +269,24 @@ AutowiredArguments resolveArguments(RegisteredBean registeredBean) { } private AutowiredArguments resolveArguments(RegisteredBean registeredBean, Executable executable) { - Assert.isInstanceOf(AbstractAutowireCapableBeanFactory.class, registeredBean.getBeanFactory()); - - int startIndex = (executable instanceof Constructor constructor && - ClassUtils.isInnerClass(constructor.getDeclaringClass())) ? 1 : 0; int parameterCount = executable.getParameterCount(); - Object[] resolved = new Object[parameterCount - startIndex]; - Assert.isTrue(this.shortcuts == null || this.shortcuts.length == resolved.length, + Object[] resolved = new Object[parameterCount]; + Assert.isTrue(this.shortcutBeanNames == null || this.shortcutBeanNames.length == resolved.length, () -> "'shortcuts' must contain " + resolved.length + " elements"); ValueHolder[] argumentValues = resolveArgumentValues(registeredBean, executable); Set autowiredBeanNames = new LinkedHashSet<>(resolved.length * 2); + int startIndex = (executable instanceof Constructor constructor && + ClassUtils.isInnerClass(constructor.getDeclaringClass())) ? 1 : 0; for (int i = startIndex; i < parameterCount; i++) { MethodParameter parameter = getMethodParameter(executable, i); DependencyDescriptor descriptor = new DependencyDescriptor(parameter, true); - String shortcut = (this.shortcuts != null ? this.shortcuts[i - startIndex] : null); + String shortcut = (this.shortcutBeanNames != null ? this.shortcutBeanNames[i] : null); if (shortcut != null) { descriptor = new ShortcutDependencyDescriptor(descriptor, shortcut); } ValueHolder argumentValue = argumentValues[i]; - resolved[i - startIndex] = resolveAutowiredArgument( + resolved[i] = resolveAutowiredArgument( registeredBean, descriptor, argumentValue, autowiredBeanNames); } registerDependentBeans(registeredBean.getBeanFactory(), registeredBean.getBeanName(), autowiredBeanNames); @@ -290,7 +314,7 @@ private ValueHolder[] resolveArgumentValues(RegisteredBean registeredBean, Execu beanFactory, registeredBean.getBeanName(), beanDefinition, beanFactory.getTypeConverter()); ConstructorArgumentValues values = resolveConstructorArguments( valueResolver, beanDefinition.getConstructorArgumentValues()); - Set usedValueHolders = new HashSet<>(parameters.length); + Set usedValueHolders = CollectionUtils.newHashSet(parameters.length); for (int i = 0; i < parameters.length; i++) { Class parameterType = parameters[i].getType(); String parameterName = (parameters[i].isNamePresent() ? parameters[i].getName() : null); @@ -346,62 +370,30 @@ private Object resolveAutowiredArgument(RegisteredBean registeredBean, Dependenc } } - @SuppressWarnings("unchecked") - private T instantiate(ConfigurableBeanFactory beanFactory, Executable executable, Object[] args) { + private Object instantiate(RegisteredBean registeredBean, Executable executable, Object[] args) { if (executable instanceof Constructor constructor) { - try { - return (T) instantiate(constructor, args); - } - catch (Exception ex) { - throw new BeanInstantiationException(constructor, ex.getMessage(), ex); - } + return BeanUtils.instantiateClass(constructor, args); } if (executable instanceof Method method) { + Object target = null; + String factoryBeanName = registeredBean.getMergedBeanDefinition().getFactoryBeanName(); + if (factoryBeanName != null) { + target = registeredBean.getBeanFactory().getBean(factoryBeanName, method.getDeclaringClass()); + } + else if (!Modifier.isStatic(method.getModifiers())) { + throw new IllegalStateException("Cannot invoke instance method without factoryBeanName: " + method); + } try { - return (T) instantiate(beanFactory, method, args); + ReflectionUtils.makeAccessible(method); + return method.invoke(target, args); } - catch (Exception ex) { + catch (Throwable ex) { throw new BeanInstantiationException(method, ex.getMessage(), ex); } } throw new IllegalStateException("Unsupported executable " + executable.getClass().getName()); } - private Object instantiate(Constructor constructor, Object[] args) throws Exception { - Class declaringClass = constructor.getDeclaringClass(); - if (ClassUtils.isInnerClass(declaringClass)) { - Object enclosingInstance = createInstance(declaringClass.getEnclosingClass()); - args = ObjectUtils.addObjectToArray(args, enclosingInstance, 0); - } - return BeanUtils.instantiateClass(constructor, args); - } - - private Object instantiate(ConfigurableBeanFactory beanFactory, Method method, Object[] args) throws Exception { - Object target = getFactoryMethodTarget(beanFactory, method); - ReflectionUtils.makeAccessible(method); - return method.invoke(target, args); - } - - @Nullable - private Object getFactoryMethodTarget(BeanFactory beanFactory, Method method) { - if (Modifier.isStatic(method.getModifiers())) { - return null; - } - Class declaringClass = method.getDeclaringClass(); - return beanFactory.getBean(declaringClass); - } - - private Object createInstance(Class clazz) throws Exception { - if (!ClassUtils.isInnerClass(clazz)) { - Constructor constructor = clazz.getDeclaredConstructor(); - ReflectionUtils.makeAccessible(constructor); - return constructor.newInstance(); - } - Class enclosingClass = clazz.getEnclosingClass(); - Constructor constructor = clazz.getDeclaredConstructor(enclosingClass); - return constructor.newInstance(createInstance(enclosingClass)); - } - private static String toCommaSeparatedNames(Class... parameterTypes) { return Arrays.stream(parameterTypes).map(Class::getName).collect(Collectors.joining(", ")); @@ -430,12 +422,9 @@ private static class ConstructorLookup extends ExecutableLookup { @Override public Executable get(RegisteredBean registeredBean) { - Class beanClass = registeredBean.getBeanClass(); + Class beanClass = registeredBean.getMergedBeanDefinition().getBeanClass(); try { - Class[] actualParameterTypes = (!ClassUtils.isInnerClass(beanClass)) ? - this.parameterTypes : ObjectUtils.addObjectToArray( - this.parameterTypes, beanClass.getEnclosingClass(), 0); - return beanClass.getDeclaredConstructor(actualParameterTypes); + return beanClass.getDeclaredConstructor(this.parameterTypes); } catch (NoSuchMethodException ex) { throw new IllegalArgumentException( @@ -461,6 +450,9 @@ private static class FactoryMethodLookup extends ExecutableLookup { private final Class[] parameterTypes; + @Nullable + private volatile Method resolvedMethod; + FactoryMethodLookup(Class declaringClass, String methodName, Class[] parameterTypes) { this.declaringClass = declaringClass; this.methodName = methodName; @@ -473,8 +465,13 @@ public Executable get(RegisteredBean registeredBean) { } Method get() { - Method method = ReflectionUtils.findMethod(this.declaringClass, this.methodName, this.parameterTypes); - Assert.notNull(method, () -> "%s cannot be found".formatted(this)); + Method method = this.resolvedMethod; + if (method == null) { + method = ReflectionUtils.findMethod( + ClassUtils.getUserClass(this.declaringClass), this.methodName, this.parameterTypes); + Assert.notNull(method, () -> "%s cannot be found".formatted(this)); + this.resolvedMethod = method; + } return method; } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationAotProcessor.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationAotProcessor.java index 5e2c17169610..30a57779a2c8 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationAotProcessor.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationAotProcessor.java @@ -49,6 +49,15 @@ @FunctionalInterface public interface BeanRegistrationAotProcessor { + /** + * The name of an attribute that can be + * {@link org.springframework.core.AttributeAccessor#setAttribute set} on a + * {@link org.springframework.beans.factory.config.BeanDefinition} to signal + * that its registration should not be processed. + * @since 6.2 + */ + String IGNORE_REGISTRATION_ATTRIBUTE = "aotProcessingIgnoreRegistration"; + /** * Process the given {@link RegisteredBean} instance ahead-of-time and * return a contribution or {@code null}. diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationsAotContribution.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationsAotContribution.java index 5960d80952d1..ddb3ba60cdf6 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationsAotContribution.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationsAotContribution.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.beans.factory.aot; -import java.util.Map; +import java.util.List; import javax.lang.model.element.Modifier; @@ -30,10 +30,11 @@ import org.springframework.aot.hint.ReflectionHints; import org.springframework.aot.hint.RuntimeHints; import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.RegisteredBean; import org.springframework.javapoet.ClassName; import org.springframework.javapoet.CodeBlock; +import org.springframework.javapoet.CodeBlock.Builder; import org.springframework.javapoet.MethodSpec; -import org.springframework.util.ClassUtils; /** * AOT contribution from a {@link BeanRegistrationsAotProcessor} used to @@ -51,10 +52,13 @@ class BeanRegistrationsAotContribution private static final String BEAN_FACTORY_PARAMETER_NAME = "beanFactory"; - private final Map registrations; + private static final ArgumentCodeGenerator argumentCodeGenerator = ArgumentCodeGenerator + .of(DefaultListableBeanFactory.class, BEAN_FACTORY_PARAMETER_NAME); + private final List registrations; - BeanRegistrationsAotContribution(Map registrations) { + + BeanRegistrationsAotContribution(List registrations) { this.registrations = registrations; } @@ -69,8 +73,8 @@ public void applyTo(GenerationContext generationContext, type.addModifiers(Modifier.PUBLIC); }); BeanRegistrationsCodeGenerator codeGenerator = new BeanRegistrationsCodeGenerator(generatedClass); - GeneratedMethod generatedBeanDefinitionsMethod = codeGenerator.getMethods().add("registerBeanDefinitions", method -> - generateRegisterBeanDefinitionsMethod(method, generationContext, codeGenerator)); + GeneratedMethod generatedBeanDefinitionsMethod = new BeanDefinitionsRegistrationGenerator( + generationContext, codeGenerator, this.registrations).generateRegisterBeanDefinitionsMethod(); beanFactoryInitializationCode.addInitializer(generatedBeanDefinitionsMethod.toMethodReference()); GeneratedMethod generatedAliasesMethod = codeGenerator.getMethods().add("registerAliases", this::generateRegisterAliasesMethod); @@ -78,66 +82,42 @@ public void applyTo(GenerationContext generationContext, generateRegisterHints(generationContext.getRuntimeHints(), this.registrations); } - private void generateRegisterBeanDefinitionsMethod(MethodSpec.Builder method, - GenerationContext generationContext, BeanRegistrationsCode beanRegistrationsCode) { - - method.addJavadoc("Register the bean definitions."); - method.addModifiers(Modifier.PUBLIC); - method.addParameter(DefaultListableBeanFactory.class, BEAN_FACTORY_PARAMETER_NAME); - CodeBlock.Builder code = CodeBlock.builder(); - this.registrations.forEach((registeredBean, registration) -> { - MethodReference beanDefinitionMethod = registration.methodGenerator - .generateBeanDefinitionMethod(generationContext, beanRegistrationsCode); - CodeBlock methodInvocation = beanDefinitionMethod.toInvokeCodeBlock( - ArgumentCodeGenerator.none(), beanRegistrationsCode.getClassName()); - code.addStatement("$L.registerBeanDefinition($S, $L)", - BEAN_FACTORY_PARAMETER_NAME, registeredBean.beanName(), methodInvocation); - }); - method.addCode(code.build()); - } - private void generateRegisterAliasesMethod(MethodSpec.Builder method) { method.addJavadoc("Register the aliases."); method.addModifiers(Modifier.PUBLIC); method.addParameter(DefaultListableBeanFactory.class, BEAN_FACTORY_PARAMETER_NAME); CodeBlock.Builder code = CodeBlock.builder(); - this.registrations.forEach((registeredBean, registration) -> { - for (String alias : registration.aliases) { + this.registrations.forEach(registration -> { + for (String alias : registration.aliases()) { code.addStatement("$L.registerAlias($S, $S)", BEAN_FACTORY_PARAMETER_NAME, - registeredBean.beanName(), alias); + registration.beanName(), alias); } }); method.addCode(code.build()); } - private void generateRegisterHints(RuntimeHints runtimeHints, Map registrations) { - registrations.keySet().forEach(beanRegistrationKey -> { + private void generateRegisterHints(RuntimeHints runtimeHints, List registrations) { + registrations.forEach(registration -> { ReflectionHints hints = runtimeHints.reflection(); - Class beanClass = beanRegistrationKey.beanClass(); + Class beanClass = registration.registeredBean.getBeanClass(); hints.registerType(beanClass, MemberCategory.INTROSPECT_PUBLIC_METHODS, MemberCategory.INTROSPECT_DECLARED_METHODS); - introspectPublicMethodsOnAllInterfaces(hints, beanClass); + hints.registerForInterfaces(beanClass, typeHint -> typeHint.withMembers(MemberCategory.INTROSPECT_PUBLIC_METHODS)); }); } - private void introspectPublicMethodsOnAllInterfaces(ReflectionHints hints, Class type) { - Class currentClass = type; - while (currentClass != null && currentClass != Object.class) { - for (Class interfaceType : currentClass.getInterfaces()) { - if (!ClassUtils.isJavaLanguageInterface(interfaceType)) { - hints.registerType(interfaceType, MemberCategory.INTROSPECT_PUBLIC_METHODS); - introspectPublicMethodsOnAllInterfaces(hints, interfaceType); - } - } - currentClass = currentClass.getSuperclass(); - } - } - /** * Gather the necessary information to register a particular bean. + * @param registeredBean the bean to register * @param methodGenerator the {@link BeanDefinitionMethodGenerator} to use * @param aliases the bean aliases, if any */ - record Registration(BeanDefinitionMethodGenerator methodGenerator, String[] aliases) {} + record Registration(RegisteredBean registeredBean, BeanDefinitionMethodGenerator methodGenerator, String[] aliases) { + + String beanName() { + return this.registeredBean.getBeanName(); + } + + } /** @@ -164,4 +144,89 @@ public GeneratedMethods getMethods() { } + static final class BeanDefinitionsRegistrationGenerator { + + private final GenerationContext generationContext; + + private final BeanRegistrationsCodeGenerator codeGenerator; + + private final List registrations; + + + BeanDefinitionsRegistrationGenerator(GenerationContext generationContext, + BeanRegistrationsCodeGenerator codeGenerator, List registrations) { + + this.generationContext = generationContext; + this.codeGenerator = codeGenerator; + this.registrations = registrations; + } + + + GeneratedMethod generateRegisterBeanDefinitionsMethod() { + return this.codeGenerator.getMethods().add("registerBeanDefinitions", method -> { + method.addJavadoc("Register the bean definitions."); + method.addModifiers(Modifier.PUBLIC); + method.addParameter(DefaultListableBeanFactory.class, BEAN_FACTORY_PARAMETER_NAME); + if (this.registrations.size() <= 1000) { + generateRegisterBeanDefinitionMethods(method, this.registrations); + } + else { + Builder code = CodeBlock.builder(); + code.add("// Registration is sliced to avoid exceeding size limit\n"); + int index = 0; + int end = 0; + while (end < this.registrations.size()) { + int start = index * 1000; + end = Math.min(start + 1000, this.registrations.size()); + GeneratedMethod sliceMethod = generateSliceMethod(start, end); + code.addStatement(sliceMethod.toMethodReference().toInvokeCodeBlock( + argumentCodeGenerator, this.codeGenerator.getClassName())); + index++; + } + method.addCode(code.build()); + } + }); + } + + private GeneratedMethod generateSliceMethod(int start, int end) { + String description = "Register the bean definitions from %s to %s.".formatted(start, end - 1); + List slice = this.registrations.subList(start, end); + return this.codeGenerator.getMethods().add("registerBeanDefinitions", method -> { + method.addJavadoc(description); + method.addModifiers(Modifier.PRIVATE); + method.addParameter(DefaultListableBeanFactory.class, BEAN_FACTORY_PARAMETER_NAME); + generateRegisterBeanDefinitionMethods(method, slice); + }); + } + + + private void generateRegisterBeanDefinitionMethods(MethodSpec.Builder method, + Iterable registrations) { + + CodeBlock.Builder code = CodeBlock.builder(); + registrations.forEach(registration -> { + try { + CodeBlock methodInvocation = generateBeanRegistration(registration); + code.addStatement("$L.registerBeanDefinition($S, $L)", + BEAN_FACTORY_PARAMETER_NAME, registration.beanName(), methodInvocation); + } + catch (AotException ex) { + throw ex; + } + catch (Exception ex) { + throw new AotBeanProcessingException(registration.registeredBean, + "failed to generate code for bean definition", ex); + } + }); + method.addCode(code.build()); + } + + private CodeBlock generateBeanRegistration(Registration registration) { + MethodReference beanDefinitionMethod = registration.methodGenerator + .generateBeanDefinitionMethod(this.generationContext, this.codeGenerator); + return beanDefinitionMethod.toInvokeCodeBlock( + ArgumentCodeGenerator.none(), this.codeGenerator.getClassName()); + } + } + } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationsAotProcessor.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationsAotProcessor.java index 05df3e7a7c8b..183ad41af15c 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationsAotProcessor.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationsAotProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 +16,8 @@ package org.springframework.beans.factory.aot; -import java.util.LinkedHashMap; -import java.util.Map; +import java.util.ArrayList; +import java.util.List; import org.springframework.beans.factory.aot.BeanRegistrationsAotContribution.Registration; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; @@ -41,15 +41,15 @@ class BeanRegistrationsAotProcessor implements BeanFactoryInitializationAotProce public BeanRegistrationsAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory) { BeanDefinitionMethodGeneratorFactory beanDefinitionMethodGeneratorFactory = new BeanDefinitionMethodGeneratorFactory(beanFactory); - Map registrations = new LinkedHashMap<>(); + List registrations = new ArrayList<>(); for (String beanName : beanFactory.getBeanDefinitionNames()) { RegisteredBean registeredBean = RegisteredBean.of(beanFactory, beanName); BeanDefinitionMethodGenerator beanDefinitionMethodGenerator = beanDefinitionMethodGeneratorFactory.getBeanDefinitionMethodGenerator(registeredBean); if (beanDefinitionMethodGenerator != null) { - registrations.put(new BeanRegistrationKey(beanName, registeredBean.getBeanClass()), - new Registration(beanDefinitionMethodGenerator, beanFactory.getAliases(beanName))); + registrations.add(new Registration(registeredBean, beanDefinitionMethodGenerator, + beanFactory.getAliases(beanName))); } } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/CodeWarnings.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/CodeWarnings.java index 7809ba2ca17f..06e5eb0d33a0 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/CodeWarnings.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/CodeWarnings.java @@ -20,7 +20,6 @@ import java.util.Collections; import java.util.LinkedHashSet; import java.util.Set; -import java.util.StringJoiner; import java.util.function.Consumer; import java.util.stream.Stream; @@ -171,9 +170,7 @@ private CodeBlock generateValueCode() { @Override public String toString() { - return new StringJoiner(", ", CodeWarnings.class.getSimpleName(), "") - .add(this.warnings.toString()) - .toString(); + return CodeWarnings.class.getSimpleName() + this.warnings; } } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/DefaultBeanRegistrationCodeFragments.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/DefaultBeanRegistrationCodeFragments.java index 1c115796a8ce..498b04633b41 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/DefaultBeanRegistrationCodeFragments.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/DefaultBeanRegistrationCodeFragments.java @@ -78,10 +78,7 @@ class DefaultBeanRegistrationCodeFragments implements BeanRegistrationCodeFragme @Override public ClassName getTarget(RegisteredBean registeredBean) { if (hasInstanceSupplier()) { - String resourceDescription = registeredBean.getMergedBeanDefinition().getResourceDescription(); - throw new IllegalStateException("Error processing bean with name '" + registeredBean.getBeanName() + "'" + - (resourceDescription != null ? " defined in " + resourceDescription : "") + - ": instance supplier is not supported"); + throw new AotBeanProcessingException(registeredBean, "instance supplier is not supported"); } Class target = extractDeclaringClass(registeredBean, this.instantiationDescriptor.get()); while (target.getName().startsWith("java.") && registeredBean.isInnerBean()) { @@ -161,7 +158,7 @@ private boolean targetTypeNecessary(ResolvableType beanType, @Nullable Class if (beanClass != null && this.registeredBean.getMergedBeanDefinition().getFactoryMethodName() != null) { return true; } - return (beanClass != null && !beanType.toClass().equals(beanClass)); + return (beanClass != null && !beanType.toClass().equals(ClassUtils.getUserClass(beanClass))); } @Override @@ -232,8 +229,7 @@ public CodeBlock generateInstanceSupplierCode( boolean allowDirectSupplierShortcut) { if (hasInstanceSupplier()) { - throw new IllegalStateException("Default code generation is not supported for bean definitions " + - "declaring an instance supplier callback: " + this.registeredBean.getMergedBeanDefinition()); + throw new AotBeanProcessingException(this.registeredBean, "instance supplier is not supported"); } return new InstanceSupplierCodeGenerator(generationContext, beanRegistrationCode.getClassName(), beanRegistrationCode.getMethods(), allowDirectSupplierShortcut) diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGenerator.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGenerator.java index acc796df5827..b70c6f287dab 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGenerator.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGenerator.java @@ -20,7 +20,6 @@ import java.lang.reflect.Executable; import java.lang.reflect.Member; import java.lang.reflect.Method; -import java.lang.reflect.Modifier; import java.lang.reflect.Parameter; import java.lang.reflect.Proxy; import java.util.Arrays; @@ -55,6 +54,7 @@ import org.springframework.javapoet.CodeBlock.Builder; import org.springframework.javapoet.MethodSpec; import org.springframework.javapoet.ParameterizedTypeName; +import org.springframework.lang.Nullable; import org.springframework.util.ClassUtils; import org.springframework.util.function.ThrowingSupplier; @@ -67,7 +67,7 @@ *

Generated code is usually a method reference that generates the * {@link BeanInstanceSupplier}, but some shortcut can be used as well such as: *

- * {@code InstanceSupplier.of(TheGeneratedClass::getMyBeanInstance);}
+ * InstanceSupplier.of(TheGeneratedClass::getMyBeanInstance);
  * 
* * @author Phillip Webb @@ -83,9 +83,8 @@ public class InstanceSupplierCodeGenerator { private static final String ARGS_PARAMETER_NAME = "args"; - private static final javax.lang.model.element.Modifier[] PRIVATE_STATIC = { - javax.lang.model.element.Modifier.PRIVATE, - javax.lang.model.element.Modifier.STATIC }; + private static final javax.lang.model.element.Modifier[] PRIVATE_STATIC = + {javax.lang.model.element.Modifier.PRIVATE, javax.lang.model.element.Modifier.STATIC}; private static final CodeBlock NO_ARGS = CodeBlock.of(""); @@ -100,7 +99,7 @@ public class InstanceSupplierCodeGenerator { /** - * Create a new instance. + * Create a new generator instance. * @param generationContext the generation context * @param className the class name of the bean to instantiate * @param generatedMethods the generated methods @@ -142,165 +141,150 @@ public CodeBlock generateCode(RegisteredBean registeredBean, InstantiationDescri if (constructorOrFactoryMethod instanceof Constructor constructor) { return generateCodeForConstructor(registeredBean, constructor); } - if (constructorOrFactoryMethod instanceof Method method) { + if (constructorOrFactoryMethod instanceof Method method && !KotlinDetector.isSuspendingFunction(method)) { return generateCodeForFactoryMethod(registeredBean, method, instantiationDescriptor.targetClass()); } - throw new IllegalStateException( - "No suitable executor found for " + registeredBean.getBeanName()); + throw new AotBeanProcessingException(registeredBean, "no suitable constructor or factory method found"); } private void registerRuntimeHintsIfNecessary(RegisteredBean registeredBean, Executable constructorOrFactoryMethod) { if (registeredBean.getBeanFactory() instanceof DefaultListableBeanFactory dlbf) { RuntimeHints runtimeHints = this.generationContext.getRuntimeHints(); ProxyRuntimeHintsRegistrar registrar = new ProxyRuntimeHintsRegistrar(dlbf.getAutowireCandidateResolver()); - if (constructorOrFactoryMethod instanceof Method method) { - registrar.registerRuntimeHints(runtimeHints, method); - } - else if (constructorOrFactoryMethod instanceof Constructor constructor) { - registrar.registerRuntimeHints(runtimeHints, constructor); - } + registrar.registerRuntimeHints(runtimeHints, constructorOrFactoryMethod); } } private CodeBlock generateCodeForConstructor(RegisteredBean registeredBean, Constructor constructor) { - String beanName = registeredBean.getBeanName(); - Class beanClass = registeredBean.getBeanClass(); - Class declaringClass = constructor.getDeclaringClass(); - boolean dependsOnBean = ClassUtils.isInnerClass(declaringClass); - - Visibility accessVisibility = getAccessVisibility(registeredBean, constructor); - if (KotlinDetector.isKotlinReflectPresent() && KotlinDelegate.hasConstructorWithOptionalParameter(beanClass)) { - return generateCodeForInaccessibleConstructor(beanName, beanClass, constructor, - dependsOnBean, hints -> hints.registerType(beanClass, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)); + ConstructorDescriptor descriptor = new ConstructorDescriptor( + registeredBean.getBeanName(), constructor, registeredBean.getBeanClass()); + + Class publicType = descriptor.publicType(); + if (KotlinDetector.isKotlinReflectPresent() && KotlinDelegate.hasConstructorWithOptionalParameter(publicType)) { + return generateCodeForInaccessibleConstructor(descriptor, + hints -> hints.registerType(publicType, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)); } - else if (accessVisibility != Visibility.PRIVATE) { - return generateCodeForAccessibleConstructor(beanName, beanClass, constructor, - dependsOnBean, declaringClass); + + if (!isVisible(constructor, constructor.getDeclaringClass())) { + return generateCodeForInaccessibleConstructor(descriptor, + hints -> hints.registerConstructor(constructor, ExecutableMode.INVOKE)); } - return generateCodeForInaccessibleConstructor(beanName, beanClass, constructor, dependsOnBean, - hints -> hints.registerConstructor(constructor, ExecutableMode.INVOKE)); + return generateCodeForAccessibleConstructor(descriptor); } - private CodeBlock generateCodeForAccessibleConstructor(String beanName, Class beanClass, - Constructor constructor, boolean dependsOnBean, Class declaringClass) { - + private CodeBlock generateCodeForAccessibleConstructor(ConstructorDescriptor descriptor) { + Constructor constructor = descriptor.constructor(); this.generationContext.getRuntimeHints().reflection().registerConstructor( constructor, ExecutableMode.INTROSPECT); - if (!dependsOnBean && constructor.getParameterCount() == 0) { + if (constructor.getParameterCount() == 0) { if (!this.allowDirectSupplierShortcut) { - return CodeBlock.of("$T.using($T::new)", InstanceSupplier.class, declaringClass); + return CodeBlock.of("$T.using($T::new)", InstanceSupplier.class, descriptor.actualType()); } if (!isThrowingCheckedException(constructor)) { - return CodeBlock.of("$T::new", declaringClass); + return CodeBlock.of("$T::new", descriptor.actualType()); } - return CodeBlock.of("$T.of($T::new)", ThrowingSupplier.class, declaringClass); + return CodeBlock.of("$T.of($T::new)", ThrowingSupplier.class, descriptor.actualType()); } GeneratedMethod generatedMethod = generateGetInstanceSupplierMethod(method -> - buildGetInstanceMethodForConstructor(method, beanName, beanClass, constructor, - declaringClass, dependsOnBean, PRIVATE_STATIC)); + buildGetInstanceMethodForConstructor(method, descriptor, PRIVATE_STATIC)); return generateReturnStatement(generatedMethod); } - private CodeBlock generateCodeForInaccessibleConstructor(String beanName, Class beanClass, - Constructor constructor, boolean dependsOnBean, Consumer hints) { + private CodeBlock generateCodeForInaccessibleConstructor(ConstructorDescriptor descriptor, + Consumer hints) { + Constructor constructor = descriptor.constructor(); CodeWarnings codeWarnings = new CodeWarnings(); - codeWarnings.detectDeprecation(beanClass, constructor) + codeWarnings.detectDeprecation(constructor.getDeclaringClass(), constructor) .detectDeprecation(Arrays.stream(constructor.getParameters()).map(Parameter::getType)); hints.accept(this.generationContext.getRuntimeHints().reflection()); GeneratedMethod generatedMethod = generateGetInstanceSupplierMethod(method -> { - method.addJavadoc("Get the bean instance supplier for '$L'.", beanName); + method.addJavadoc("Get the bean instance supplier for '$L'.", descriptor.beanName()); method.addModifiers(PRIVATE_STATIC); codeWarnings.suppress(method); - method.returns(ParameterizedTypeName.get(BeanInstanceSupplier.class, beanClass)); - int parameterOffset = (!dependsOnBean) ? 0 : 1; - method.addStatement(generateResolverForConstructor(beanClass, constructor, parameterOffset)); + method.returns(ParameterizedTypeName.get(BeanInstanceSupplier.class, descriptor.publicType())); + method.addStatement(generateResolverForConstructor(descriptor)); }); return generateReturnStatement(generatedMethod); } - private void buildGetInstanceMethodForConstructor(MethodSpec.Builder method, - String beanName, Class beanClass, Constructor constructor, Class declaringClass, - boolean dependsOnBean, javax.lang.model.element.Modifier... modifiers) { + private void buildGetInstanceMethodForConstructor(MethodSpec.Builder method, ConstructorDescriptor descriptor, + javax.lang.model.element.Modifier... modifiers) { + + Constructor constructor = descriptor.constructor(); + Class publicType = descriptor.publicType(); + Class actualType = descriptor.actualType(); CodeWarnings codeWarnings = new CodeWarnings(); - codeWarnings.detectDeprecation(beanClass, constructor, declaringClass) + codeWarnings.detectDeprecation(actualType, constructor) .detectDeprecation(Arrays.stream(constructor.getParameters()).map(Parameter::getType)); - method.addJavadoc("Get the bean instance supplier for '$L'.", beanName); + method.addJavadoc("Get the bean instance supplier for '$L'.", descriptor.beanName()); method.addModifiers(modifiers); codeWarnings.suppress(method); - method.returns(ParameterizedTypeName.get(BeanInstanceSupplier.class, beanClass)); + method.returns(ParameterizedTypeName.get(BeanInstanceSupplier.class, publicType)); - int parameterOffset = (!dependsOnBean) ? 0 : 1; CodeBlock.Builder code = CodeBlock.builder(); - code.add(generateResolverForConstructor(beanClass, constructor, parameterOffset)); + code.add(generateResolverForConstructor(descriptor)); boolean hasArguments = constructor.getParameterCount() > 0; + boolean onInnerClass = ClassUtils.isInnerClass(actualType); CodeBlock arguments = hasArguments ? - new AutowiredArgumentsCodeGenerator(declaringClass, constructor) - .generateCode(constructor.getParameterTypes(), parameterOffset) + new AutowiredArgumentsCodeGenerator(actualType, constructor) + .generateCode(constructor.getParameterTypes(), (onInnerClass ? 1 : 0)) : NO_ARGS; - CodeBlock newInstance = generateNewInstanceCodeForConstructor(dependsOnBean, declaringClass, arguments); + CodeBlock newInstance = generateNewInstanceCodeForConstructor(actualType, arguments); code.add(generateWithGeneratorCode(hasArguments, newInstance)); method.addStatement(code.build()); } - private CodeBlock generateResolverForConstructor(Class beanClass, - Constructor constructor, int parameterOffset) { - - CodeBlock parameterTypes = generateParameterTypesCode(constructor.getParameterTypes(), parameterOffset); - return CodeBlock.of("return $T.<$T>forConstructor($L)", BeanInstanceSupplier.class, beanClass, parameterTypes); + private CodeBlock generateResolverForConstructor(ConstructorDescriptor descriptor) { + CodeBlock parameterTypes = generateParameterTypesCode(descriptor.constructor().getParameterTypes()); + return CodeBlock.of("return $T.<$T>forConstructor($L)", BeanInstanceSupplier.class, + descriptor.publicType(), parameterTypes); } - private CodeBlock generateNewInstanceCodeForConstructor(boolean dependsOnBean, - Class declaringClass, CodeBlock args) { - - if (!dependsOnBean) { - return CodeBlock.of("new $T($L)", declaringClass, args); + private CodeBlock generateNewInstanceCodeForConstructor(Class declaringClass, CodeBlock args) { + if (ClassUtils.isInnerClass(declaringClass)) { + return CodeBlock.of("$L.getBeanFactory().getBean($T.class).new $L($L)", + REGISTERED_BEAN_PARAMETER_NAME, declaringClass.getEnclosingClass(), + declaringClass.getSimpleName(), args); } - - return CodeBlock.of("$L.getBeanFactory().getBean($T.class).new $L($L)", - REGISTERED_BEAN_PARAMETER_NAME, declaringClass.getEnclosingClass(), - declaringClass.getSimpleName(), args); + return CodeBlock.of("new $T($L)", declaringClass, args); } - private CodeBlock generateCodeForFactoryMethod(RegisteredBean registeredBean, Method factoryMethod, Class targetClass) { - String beanName = registeredBean.getBeanName(); - Class targetClassToUse = ClassUtils.getUserClass(targetClass); - boolean dependsOnBean = !Modifier.isStatic(factoryMethod.getModifiers()); + private CodeBlock generateCodeForFactoryMethod( + RegisteredBean registeredBean, Method factoryMethod, Class targetClass) { - Visibility accessVisibility = getAccessVisibility(registeredBean, factoryMethod); - if (accessVisibility != Visibility.PRIVATE) { - return generateCodeForAccessibleFactoryMethod( - beanName, factoryMethod, targetClassToUse, dependsOnBean); + if (!isVisible(factoryMethod, targetClass)) { + return generateCodeForInaccessibleFactoryMethod(registeredBean.getBeanName(), factoryMethod, targetClass); } - return generateCodeForInaccessibleFactoryMethod(beanName, factoryMethod, targetClassToUse); + return generateCodeForAccessibleFactoryMethod(registeredBean.getBeanName(), factoryMethod, targetClass, + registeredBean.getMergedBeanDefinition().getFactoryBeanName()); } private CodeBlock generateCodeForAccessibleFactoryMethod(String beanName, - Method factoryMethod, Class targetClass, boolean dependsOnBean) { + Method factoryMethod, Class targetClass, @Nullable String factoryBeanName) { - this.generationContext.getRuntimeHints().reflection().registerMethod( - factoryMethod, ExecutableMode.INTROSPECT); + this.generationContext.getRuntimeHints().reflection().registerMethod(factoryMethod, ExecutableMode.INTROSPECT); - if (!dependsOnBean && factoryMethod.getParameterCount() == 0) { + if (factoryBeanName == null && factoryMethod.getParameterCount() == 0) { Class suppliedType = ClassUtils.resolvePrimitiveIfNecessary(factoryMethod.getReturnType()); CodeBlock.Builder code = CodeBlock.builder(); code.add("$T.<$T>forFactoryMethod($T.class, $S)", BeanInstanceSupplier.class, suppliedType, targetClass, factoryMethod.getName()); code.add(".withGenerator(($L) -> $T.$L())", REGISTERED_BEAN_PARAMETER_NAME, - targetClass, factoryMethod.getName()); + ClassUtils.getUserClass(targetClass), factoryMethod.getName()); return code.build(); } GeneratedMethod getInstanceMethod = generateGetInstanceSupplierMethod(method -> buildGetInstanceMethodForFactoryMethod(method, beanName, factoryMethod, - targetClass, dependsOnBean, PRIVATE_STATIC)); + targetClass, factoryBeanName, PRIVATE_STATIC)); return generateReturnStatement(getInstanceMethod); } @@ -321,12 +305,12 @@ private CodeBlock generateCodeForInaccessibleFactoryMethod( private void buildGetInstanceMethodForFactoryMethod(MethodSpec.Builder method, String beanName, Method factoryMethod, Class targetClass, - boolean dependsOnBean, javax.lang.model.element.Modifier... modifiers) { + @Nullable String factoryBeanName, javax.lang.model.element.Modifier... modifiers) { String factoryMethodName = factoryMethod.getName(); Class suppliedType = ClassUtils.resolvePrimitiveIfNecessary(factoryMethod.getReturnType()); CodeWarnings codeWarnings = new CodeWarnings(); - codeWarnings.detectDeprecation(targetClass, factoryMethod, suppliedType) + codeWarnings.detectDeprecation(ClassUtils.getUserClass(targetClass), factoryMethod, suppliedType) .detectDeprecation(Arrays.stream(factoryMethod.getParameters()).map(Parameter::getType)); method.addJavadoc("Get the bean instance supplier for '$L'.", beanName); @@ -340,12 +324,12 @@ private void buildGetInstanceMethodForFactoryMethod(MethodSpec.Builder method, boolean hasArguments = factoryMethod.getParameterCount() > 0; CodeBlock arguments = hasArguments ? - new AutowiredArgumentsCodeGenerator(targetClass, factoryMethod) + new AutowiredArgumentsCodeGenerator(ClassUtils.getUserClass(targetClass), factoryMethod) .generateCode(factoryMethod.getParameterTypes()) : NO_ARGS; CodeBlock newInstance = generateNewInstanceCodeForMethod( - dependsOnBean, targetClass, factoryMethodName, arguments); + factoryBeanName, ClassUtils.getUserClass(targetClass), factoryMethodName, arguments); code.add(generateWithGeneratorCode(hasArguments, newInstance)); method.addStatement(code.build()); } @@ -358,19 +342,19 @@ private CodeBlock generateInstanceSupplierForFactoryMethod(Method factoryMethod, BeanInstanceSupplier.class, suppliedType, targetClass, factoryMethodName); } - CodeBlock parameterTypes = generateParameterTypesCode(factoryMethod.getParameterTypes(), 0); + CodeBlock parameterTypes = generateParameterTypesCode(factoryMethod.getParameterTypes()); return CodeBlock.of("return $T.<$T>forFactoryMethod($T.class, $S, $L)", BeanInstanceSupplier.class, suppliedType, targetClass, factoryMethodName, parameterTypes); } - private CodeBlock generateNewInstanceCodeForMethod(boolean dependsOnBean, + private CodeBlock generateNewInstanceCodeForMethod(@Nullable String factoryBeanName, Class targetClass, String factoryMethodName, CodeBlock args) { - if (!dependsOnBean) { + if (factoryBeanName == null) { return CodeBlock.of("$T.$L($L)", targetClass, factoryMethodName, args); } - return CodeBlock.of("$L.getBeanFactory().getBean($T.class).$L($L)", - REGISTERED_BEAN_PARAMETER_NAME, targetClass, factoryMethodName, args); + return CodeBlock.of("$L.getBeanFactory().getBean(\"$L\", $T.class).$L($L)", + REGISTERED_BEAN_PARAMETER_NAME, factoryBeanName, targetClass, factoryMethodName, args); } private CodeBlock generateReturnStatement(GeneratedMethod generatedMethod) { @@ -390,16 +374,18 @@ private CodeBlock generateWithGeneratorCode(boolean hasArguments, CodeBlock newI return code.build(); } - private Visibility getAccessVisibility(RegisteredBean registeredBean, Member member) { - AccessControl beanTypeAccessControl = AccessControl.forResolvableType(registeredBean.getBeanType()); + private boolean isVisible(Member member, Class targetClass) { + AccessControl classAccessControl = AccessControl.forClass(targetClass); AccessControl memberAccessControl = AccessControl.forMember(member); - return AccessControl.lowest(beanTypeAccessControl, memberAccessControl).getVisibility(); - } + Visibility visibility = AccessControl.lowest(classAccessControl, memberAccessControl).getVisibility(); + return (visibility == Visibility.PUBLIC || (visibility != Visibility.PRIVATE && + member.getDeclaringClass().getPackageName().equals(this.className.packageName()))); + } - private CodeBlock generateParameterTypesCode(Class[] parameterTypes, int offset) { + private CodeBlock generateParameterTypesCode(Class[] parameterTypes) { CodeBlock.Builder code = CodeBlock.builder(); - for (int i = offset; i < parameterTypes.length; i++) { - code.add(i != offset ? ", " : ""); + for (int i = 0; i < parameterTypes.length; i++) { + code.add(i > 0 ? ", " : ""); code.add("$T.class", parameterTypes[i]); } return code.build(); @@ -411,10 +397,12 @@ private GeneratedMethod generateGetInstanceSupplierMethod(Consumer beanClass) { } return false; } - } - private static class ProxyRuntimeHintsRegistrar { - - private final AutowireCandidateResolver candidateResolver; - - public ProxyRuntimeHintsRegistrar(AutowireCandidateResolver candidateResolver) { - this.candidateResolver = candidateResolver; - } + private record ProxyRuntimeHintsRegistrar(AutowireCandidateResolver candidateResolver) { - public void registerRuntimeHints(RuntimeHints runtimeHints, Method method) { - Class[] parameterTypes = method.getParameterTypes(); + public void registerRuntimeHints(RuntimeHints runtimeHints, Executable executable) { + Class[] parameterTypes = executable.getParameterTypes(); for (int i = 0; i < parameterTypes.length; i++) { - MethodParameter methodParam = new MethodParameter(method, i); + MethodParameter methodParam = MethodParameter.forExecutable(executable, i); DependencyDescriptor dependencyDescriptor = new DependencyDescriptor(methodParam, true); registerProxyIfNecessary(runtimeHints, dependencyDescriptor); } } - public void registerRuntimeHints(RuntimeHints runtimeHints, Constructor constructor) { - Class[] parameterTypes = constructor.getParameterTypes(); - for (int i = 0; i < parameterTypes.length; i++) { - MethodParameter methodParam = new MethodParameter(constructor, i); - DependencyDescriptor dependencyDescriptor = new DependencyDescriptor( - methodParam, true); - registerProxyIfNecessary(runtimeHints, dependencyDescriptor); - } - } - private void registerProxyIfNecessary(RuntimeHints runtimeHints, DependencyDescriptor dependencyDescriptor) { Class proxyType = this.candidateResolver.getLazyResolutionProxyClass(dependencyDescriptor, null); if (proxyType != null && Proxy.isProxyClass(proxyType)) { @@ -472,4 +443,11 @@ private void registerProxyIfNecessary(RuntimeHints runtimeHints, DependencyDescr } } + record ConstructorDescriptor(String beanName, Constructor constructor, Class publicType) { + + Class actualType() { + return this.constructor.getDeclaringClass(); + } + } + } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/AbstractFactoryBean.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/AbstractFactoryBean.java index 7715f33faa6b..ea7958d4d53f 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/AbstractFactoryBean.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/AbstractFactoryBean.java @@ -151,6 +151,7 @@ public void afterPropertiesSet() throws Exception { * @see #getEarlySingletonInterfaces() */ @Override + @SuppressWarnings("NullAway") public final T getObject() throws Exception { if (isSingleton()) { return (this.initialized ? this.singletonInstance : getEarlySingletonInstance()); diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/AutowireCapableBeanFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/AutowireCapableBeanFactory.java index efda24780820..cd7044c89f46 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/AutowireCapableBeanFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/AutowireCapableBeanFactory.java @@ -105,7 +105,7 @@ public interface AutowireCapableBeanFactory extends BeanFactory { /** * Suffix for the "original instance" convention when initializing an existing * bean instance: to be appended to the fully-qualified bean class name, - * e.g. "com.mypackage.MyClass.ORIGINAL", in order to enforce the given instance + * for example, "com.mypackage.MyClass.ORIGINAL", in order to enforce the given instance * to be returned, i.e. no proxies etc. * @since 5.1 * @see #initializeBean(Object, String) @@ -128,7 +128,7 @@ public interface AutowireCapableBeanFactory extends BeanFactory { * Constructor resolution is based on Kotlin primary / single public / single non-public, * with a fallback to the default constructor in ambiguous scenarios, also influenced * by {@link SmartInstantiationAwareBeanPostProcessor#determineCandidateConstructors} - * (e.g. for annotation-driven constructor selection). + * (for example, for annotation-driven constructor selection). * @param beanClass the class of the bean to create * @return the new bean instance * @throws BeansException if instantiation or wiring failed @@ -137,7 +137,7 @@ public interface AutowireCapableBeanFactory extends BeanFactory { /** * Populate the given bean instance through applying after-instantiation callbacks - * and bean property post-processing (e.g. for annotation-driven injection). + * and bean property post-processing (for example, for annotation-driven injection). *

Note: This is essentially intended for (re-)populating annotated fields and * methods, either for new instances or for deserialized instances. It does * not imply traditional by-name or by-type autowiring of properties; @@ -196,7 +196,7 @@ public interface AutowireCapableBeanFactory extends BeanFactory { * Instantiate a new bean instance of the given class with the specified autowire * strategy. All constants defined in this interface are supported here. * Can also be invoked with {@code AUTOWIRE_NO} in order to just apply - * before-instantiation callbacks (e.g. for annotation-driven injection). + * before-instantiation callbacks (for example, for annotation-driven injection). *

Does not apply standard {@link BeanPostProcessor BeanPostProcessors} * callbacks or perform any further initialization of the bean. This interface * offers distinct, fine-grained operations for those purposes, for example @@ -223,7 +223,7 @@ public interface AutowireCapableBeanFactory extends BeanFactory { /** * Autowire the bean properties of the given bean instance by name or type. * Can also be invoked with {@code AUTOWIRE_NO} in order to just apply - * after-instantiation callbacks (e.g. for annotation-driven injection). + * after-instantiation callbacks (for example, for annotation-driven injection). *

Does not apply standard {@link BeanPostProcessor BeanPostProcessors} * callbacks or perform any further initialization of the bean. This interface * offers distinct, fine-grained operations for those purposes, for example diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/BeanDefinition.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/BeanDefinition.java index 5be39a0eaa11..0f6f6ab5cb66 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/BeanDefinition.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/BeanDefinition.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -151,6 +151,9 @@ public interface BeanDefinition extends AttributeAccessor, BeanMetadataElement { /** * Set the names of the beans that this bean depends on being initialized. * The bean factory will guarantee that these beans get initialized first. + *

Note that dependencies are normally expressed through bean properties or + * constructor arguments. This property should just be necessary for other kinds + * of dependencies like statics (*ugh*) or database preparation on startup. */ void setDependsOn(@Nullable String... dependsOn); @@ -178,6 +181,7 @@ public interface BeanDefinition extends AttributeAccessor, BeanMetadataElement { * Set whether this bean is a primary autowire candidate. *

If this value is {@code true} for exactly one bean among multiple * matching candidates, it will serve as a tie-breaker. + * @see #setFallback */ void setPrimary(boolean primary); @@ -186,15 +190,37 @@ public interface BeanDefinition extends AttributeAccessor, BeanMetadataElement { */ boolean isPrimary(); + /** + * Set whether this bean is a fallback autowire candidate. + *

If this value is {@code true} for all beans but one among multiple + * matching candidates, the remaining bean will be selected. + * @since 6.2 + * @see #setPrimary + */ + void setFallback(boolean fallback); + + /** + * Return whether this bean is a fallback autowire candidate. + * @since 6.2 + */ + boolean isFallback(); + /** * Specify the factory bean to use, if any. - * This the name of the bean to call the specified factory method on. + * This is the name of the bean to call the specified factory method on. + *

A factory bean name is only necessary for instance-based factory methods. + * For static factory methods, the method will be derived from the bean class. * @see #setFactoryMethodName + * @see #setBeanClassName */ void setFactoryBeanName(@Nullable String factoryBeanName); /** * Return the factory bean name, if any. + *

This will be {@code null} for static factory methods which will + * be derived from the bean class instead. + * @see #getFactoryMethodName() + * @see #getBeanClassName() */ @Nullable String getFactoryBeanName(); @@ -211,6 +237,8 @@ public interface BeanDefinition extends AttributeAccessor, BeanMetadataElement { /** * Return a factory method, if any. + * @see #getFactoryBeanName() + * @see #getBeanClassName() */ @Nullable String getFactoryMethodName(); @@ -225,6 +253,7 @@ public interface BeanDefinition extends AttributeAccessor, BeanMetadataElement { /** * Return if there are constructor argument values defined for this bean. * @since 5.0.2 + * @see #getConstructorArgumentValues() */ default boolean hasConstructorArgumentValues() { return !getConstructorArgumentValues().isEmpty(); @@ -240,6 +269,7 @@ default boolean hasConstructorArgumentValues() { /** * Return if there are property values defined for this bean. * @since 5.0.2 + * @see #getPropertyValues() */ default boolean hasPropertyValues() { return !getPropertyValues().isEmpty(); @@ -334,7 +364,8 @@ default boolean hasPropertyValues() { boolean isPrototype(); /** - * Return whether this bean is "abstract", that is, not meant to be instantiated. + * Return whether this bean is "abstract", that is, not meant to be instantiated + * itself but rather just serving as parent for concrete child bean definitions. */ boolean isAbstract(); diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/ConfigurableBeanFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/ConfigurableBeanFactory.java index 737746018bad..e4fb00877b59 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/ConfigurableBeanFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/ConfigurableBeanFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author 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.beans.factory.config; import java.beans.PropertyEditor; +import java.util.concurrent.Executor; import org.springframework.beans.PropertyEditorRegistrar; import org.springframework.beans.PropertyEditorRegistry; @@ -146,6 +147,22 @@ public interface ConfigurableBeanFactory extends HierarchicalBeanFactory, Single @Nullable BeanExpressionResolver getBeanExpressionResolver(); + /** + * Set the {@link Executor} (possibly a {@link org.springframework.core.task.TaskExecutor}) + * for background bootstrapping. + * @since 6.2 + * @see org.springframework.beans.factory.support.AbstractBeanDefinition#setBackgroundInit + */ + void setBootstrapExecutor(@Nullable Executor executor); + + /** + * Return the {@link Executor} (possibly a {@link org.springframework.core.task.TaskExecutor}) + * for background bootstrapping, if any. + * @since 6.2 + */ + @Nullable + Executor getBootstrapExecutor(); + /** * Specify a {@link ConversionService} to use for converting * property values, as an alternative to JavaBeans PropertyEditors. @@ -166,7 +183,11 @@ public interface ConfigurableBeanFactory extends HierarchicalBeanFactory, Single * on the given registry, fresh for each bean creation attempt. This avoids * the need for synchronization on custom editors; hence, it is generally * preferable to use this method instead of {@link #registerCustomEditor}. + *

If the given registrar implements + * {@link PropertyEditorRegistrar#overridesDefaultEditors()} to return {@code true}, + * it will be applied lazily (only when default editors are actually needed). * @param registrar the PropertyEditorRegistrar to register + * @see PropertyEditorRegistrar#overridesDefaultEditors() */ void addPropertyEditorRegistrar(PropertyEditorRegistrar registrar); @@ -224,7 +245,7 @@ public interface ConfigurableBeanFactory extends HierarchicalBeanFactory, Single boolean hasEmbeddedValueResolver(); /** - * Resolve the given embedded value, e.g. an annotation attribute. + * Resolve the given embedded value, for example, an annotation attribute. * @param value the value to resolve * @return the resolved value (may be the original value as-is) * @since 3.0 @@ -238,7 +259,7 @@ public interface ConfigurableBeanFactory extends HierarchicalBeanFactory, Single *

Note: Post-processors submitted here will be applied in the order of * registration; any ordering semantics expressed through implementing the * {@link org.springframework.core.Ordered} interface will be ignored. Note - * that autodetected post-processors (e.g. as beans in an ApplicationContext) + * that autodetected post-processors (for example, as beans in an ApplicationContext) * will always be applied after programmatically registered ones. * @param beanPostProcessor the post-processor to register */ diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/ConfigurableListableBeanFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/ConfigurableListableBeanFactory.java index 249d6bc31d1b..fe451841ab42 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/ConfigurableListableBeanFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/ConfigurableListableBeanFactory.java @@ -66,13 +66,13 @@ public interface ConfigurableListableBeanFactory * Register a special dependency type with corresponding autowired value. *

This is intended for factory/context references that are supposed * to be autowirable but are not defined as beans in the factory: - * e.g. a dependency of type ApplicationContext resolved to the + * for example, a dependency of type ApplicationContext resolved to the * ApplicationContext instance that the bean is living in. *

Note: There are no such default types registered in a plain BeanFactory, * not even for the BeanFactory interface itself. * @param dependencyType the dependency type to register. This will typically * be a base interface such as BeanFactory, with extensions of it resolved - * as well if declared as an autowiring dependency (e.g. ListableBeanFactory), + * as well if declared as an autowiring dependency (for example, ListableBeanFactory), * as long as the given value actually implements the extended interface. * @param autowiredValue the corresponding autowired value. This may also be an * implementation of the {@link org.springframework.beans.factory.ObjectFactory} @@ -126,7 +126,7 @@ boolean isAutowireCandidate(String beanName, DependencyDescriptor descriptor) * Clear the merged bean definition cache, removing entries for beans * which are not considered eligible for full metadata caching yet. *

Typically triggered after changes to the original bean definitions, - * e.g. after applying a {@link BeanFactoryPostProcessor}. Note that metadata + * for example, after applying a {@link BeanFactoryPostProcessor}. Note that metadata * for beans which have already been created at this point will be kept around. * @since 4.2 * @see #getBeanDefinition diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/CustomEditorConfigurer.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/CustomEditorConfigurer.java index 9250915504c1..64963ead5435 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/CustomEditorConfigurer.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/CustomEditorConfigurer.java @@ -76,7 +76,7 @@ * *

* Also supports "java.lang.String[]"-style array class names and primitive - * class names (e.g. "boolean"). Delegates to {@link ClassUtils} for actual + * class names (for example, "boolean"). Delegates to {@link ClassUtils} for actual * class name resolution. * *

NOTE: Custom property editors registered with this configurer do diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/DependencyDescriptor.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/DependencyDescriptor.java index 96d89e3886b9..e7dc1a602a5e 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/DependencyDescriptor.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/DependencyDescriptor.java @@ -180,7 +180,7 @@ public boolean isRequired() { /** * Check whether the underlying field is annotated with any variant of a - * {@code Nullable} annotation, e.g. {@code jakarta.annotation.Nullable} or + * {@code Nullable} annotation, for example, {@code jakarta.annotation.Nullable} or * {@code edu.umd.cs.findbugs.annotations.Nullable}. */ private boolean hasNullableAnnotation() { @@ -332,6 +332,10 @@ public DependencyDescriptor forFallbackMatch() { public boolean fallbackMatchAllowed() { return true; } + @Override + public boolean usesStandardBeanLookup() { + return true; + } }; } @@ -377,7 +381,7 @@ public Class getDependencyType() { /** * Determine whether this dependency supports lazy resolution, - * e.g. through extra proxying. The default is {@code true}. + * for example, through extra proxying. The default is {@code true}. * @since 6.1.2 * @see org.springframework.beans.factory.support.AutowireCandidateResolver#getLazyResolutionProxyIfNecessary */ @@ -385,6 +389,21 @@ public boolean supportsLazyResolution() { return true; } + /** + * Determine whether this descriptor uses a standard bean lookup + * in {@link #resolveCandidate(String, Class, BeanFactory)} and + * therefore qualifies for factory-level shortcut resolution. + *

By default, the {@code DependencyDescriptor} class itself + * uses a standard bean lookup but subclasses may override this. + * If a subclass overrides other methods but preserves a standard + * bean lookup, it may override this method to return {@code true}. + * @since 6.2 + * @see #resolveCandidate(String, Class, BeanFactory) + */ + public boolean usesStandardBeanLookup() { + return (getClass() == DependencyDescriptor.class); + } + @Override public boolean equals(@Nullable Object other) { @@ -394,9 +413,9 @@ public boolean equals(@Nullable Object other) { if (!super.equals(other)) { return false; } - DependencyDescriptor otherDesc = (DependencyDescriptor) other; - return (this.required == otherDesc.required && this.eager == otherDesc.eager && - this.nestingLevel == otherDesc.nestingLevel && this.containingClass == otherDesc.containingClass); + return (other instanceof DependencyDescriptor otherDesc && this.required == otherDesc.required && + this.eager == otherDesc.eager && this.nestingLevel == otherDesc.nestingLevel && + this.containingClass == otherDesc.containingClass); } @Override diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/DeprecatedBeanWarner.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/DeprecatedBeanWarner.java index 6a1369af0f7a..ee05ce45d52a 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/DeprecatedBeanWarner.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/DeprecatedBeanWarner.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ protected void logDeprecatedBean(String beanName, Class beanType, BeanDefinit builder.append(beanName); builder.append('\''); String resourceDescription = beanDefinition.getResourceDescription(); - if (StringUtils.hasLength(resourceDescription)) { + if (StringUtils.hasText(resourceDescription)) { builder.append(" in "); builder.append(resourceDescription); } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/DestructionAwareBeanPostProcessor.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/DestructionAwareBeanPostProcessor.java index dd1c542a689b..a8ba25030aff 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/DestructionAwareBeanPostProcessor.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/DestructionAwareBeanPostProcessor.java @@ -31,7 +31,7 @@ public interface DestructionAwareBeanPostProcessor extends BeanPostProcessor { /** * Apply this BeanPostProcessor to the given bean instance before its - * destruction, e.g. invoking custom destruction callbacks. + * destruction, for example, invoking custom destruction callbacks. *

Like DisposableBean's {@code destroy} and a custom destroy method, this * callback will only apply to beans which the container fully manages the * lifecycle for. This is usually the case for singletons and scoped beans. diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/FieldRetrievingFactoryBean.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/FieldRetrievingFactoryBean.java index 86b6ccc23471..6c132145b3bd 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/FieldRetrievingFactoryBean.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/FieldRetrievingFactoryBean.java @@ -140,7 +140,7 @@ public String getTargetField() { /** * Set a fully qualified static field name to retrieve, - * e.g. "example.MyExampleClass.MY_EXAMPLE_FIELD". + * for example, "example.MyExampleClass.MY_EXAMPLE_FIELD". * Convenient alternative to specifying targetClass and targetField. * @see #setTargetClass * @see #setTargetField @@ -167,6 +167,7 @@ public void setBeanClassLoader(ClassLoader classLoader) { @Override + @SuppressWarnings("NullAway") public void afterPropertiesSet() throws ClassNotFoundException, NoSuchFieldException { if (this.targetClass != null && this.targetObject != null) { throw new IllegalArgumentException("Specify either targetClass or targetObject, not both"); @@ -189,7 +190,7 @@ public void afterPropertiesSet() throws ClassNotFoundException, NoSuchFieldExcep if (lastDotIndex == -1 || lastDotIndex == this.staticField.length()) { throw new IllegalArgumentException( "staticField must be a fully qualified class plus static field name: " + - "e.g. 'example.MyExampleClass.MY_EXAMPLE_FIELD'"); + "for example, 'example.MyExampleClass.MY_EXAMPLE_FIELD'"); } String className = this.staticField.substring(0, lastDotIndex); String fieldName = this.staticField.substring(lastDotIndex + 1); 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 91910a2f4a24..6e37fc17fb46 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-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -100,6 +100,8 @@ public abstract class PlaceholderConfigurerSupport extends PropertyResourceConfi /** Default value separator: {@value}. */ public static final String DEFAULT_VALUE_SEPARATOR = ":"; + /** Default escape character: {@code '\'}. */ + public static final Character DEFAULT_ESCAPE_CHARACTER = '\\'; /** Defaults to {@value #DEFAULT_PLACEHOLDER_PREFIX}. */ protected String placeholderPrefix = DEFAULT_PLACEHOLDER_PREFIX; @@ -111,6 +113,10 @@ public abstract class PlaceholderConfigurerSupport extends PropertyResourceConfi @Nullable protected String valueSeparator = DEFAULT_VALUE_SEPARATOR; + /** Defaults to {@link #DEFAULT_ESCAPE_CHARACTER}. */ + @Nullable + protected Character escapeCharacter = DEFAULT_ESCAPE_CHARACTER; + protected boolean trimValues = false; @Nullable @@ -151,6 +157,17 @@ public void setValueSeparator(@Nullable String valueSeparator) { this.valueSeparator = valueSeparator; } + /** + * Specify the escape character to use to ignore placeholder prefix + * or value separator, or {@code null} if no escaping should take + * place. + *

Default is {@link #DEFAULT_ESCAPE_CHARACTER}. + * @since 6.2 + */ + public void setEscapeCharacter(@Nullable Character escsEscapeCharacter) { + this.escapeCharacter = escsEscapeCharacter; + } + /** * Specify whether to trim resolved values before applying them, * removing superfluous whitespace from the beginning and end. @@ -163,7 +180,7 @@ public void setTrimValues(boolean trimValues) { /** * Set a value that should be treated as {@code null} when resolved - * as a placeholder value: e.g. "" (empty String) or "null". + * as a placeholder value: for example, "" (empty String) or "null". *

Note that this will only apply to full property values, * not to parts of concatenated values. *

By default, no such null value is defined. This means that @@ -211,7 +228,7 @@ public void setBeanFactory(BeanFactory beanFactory) { this.beanFactory = beanFactory; } - + @SuppressWarnings("NullAway") protected void doProcessProperties(ConfigurableListableBeanFactory beanFactoryToProcess, StringValueResolver valueResolver) { diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/PropertyOverrideConfigurer.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/PropertyOverrideConfigurer.java index e073d81b47db..840a34e76234 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/PropertyOverrideConfigurer.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/PropertyOverrideConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2024 the original author 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,6 @@ package org.springframework.beans.factory.config; -import java.util.Collections; import java.util.Enumeration; import java.util.Properties; import java.util.Set; @@ -77,7 +76,7 @@ public class PropertyOverrideConfigurer extends PropertyResourceConfigurer { /** * Contains names of beans that have overrides. */ - private final Set beanNames = Collections.newSetFromMap(new ConcurrentHashMap<>(16)); + private final Set beanNames = ConcurrentHashMap.newKeySet(16); /** diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/PropertyPathFactoryBean.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/PropertyPathFactoryBean.java index 348b4674b7aa..b22e52be3d26 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/PropertyPathFactoryBean.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/PropertyPathFactoryBean.java @@ -121,7 +121,7 @@ public void setTargetObject(Object targetObject) { * Specify the name of a target bean to apply the property path to. * Alternatively, specify a target object directly. * @param targetBeanName the bean name to be looked up in the - * containing bean factory (e.g. "testBean") + * containing bean factory (for example, "testBean") * @see #setTargetObject */ public void setTargetBeanName(String targetBeanName) { @@ -131,7 +131,7 @@ public void setTargetBeanName(String targetBeanName) { /** * Specify the property path to apply to the target. * @param propertyPath the property path, potentially nested - * (e.g. "age" or "spouse.age") + * (for example, "age" or "spouse.age") */ public void setPropertyPath(String propertyPath) { this.propertyPath = StringUtils.trimAllWhitespace(propertyPath); @@ -162,6 +162,7 @@ public void setBeanName(String beanName) { @Override + @SuppressWarnings("NullAway") public void setBeanFactory(BeanFactory beanFactory) { this.beanFactory = beanFactory; diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/PropertyPlaceholderConfigurer.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/PropertyPlaceholderConfigurer.java index 0fba4f79c229..51d9124850ca 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/PropertyPlaceholderConfigurer.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/PropertyPlaceholderConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -93,7 +93,7 @@ public class PropertyPlaceholderConfigurer extends PlaceholderConfigurerSupport /** * Set the system property mode by the name of the corresponding constant, - * e.g. "SYSTEM_PROPERTIES_MODE_OVERRIDE". + * for example, "SYSTEM_PROPERTIES_MODE_OVERRIDE". * @param constantName name of the constant * @see #setSystemPropertiesMode */ @@ -234,7 +234,8 @@ private class PlaceholderResolvingStringValueResolver implements StringValueReso public PlaceholderResolvingStringValueResolver(Properties props) { this.helper = new PropertyPlaceholderHelper( - placeholderPrefix, placeholderSuffix, valueSeparator, ignoreUnresolvablePlaceholders); + placeholderPrefix, placeholderSuffix, valueSeparator, + escapeCharacter, ignoreUnresolvablePlaceholders); this.resolver = new PropertyPlaceholderConfigurerResolver(props); } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/Scope.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/Scope.java index 9606eb87264b..8073dda0d912 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/Scope.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/Scope.java @@ -31,7 +31,7 @@ *

{@link org.springframework.context.ApplicationContext} implementations * such as a {@link org.springframework.web.context.WebApplicationContext} * may register additional standard scopes specific to their environment, - * e.g. {@link org.springframework.web.context.WebApplicationContext#SCOPE_REQUEST "request"} + * for example, {@link org.springframework.web.context.WebApplicationContext#SCOPE_REQUEST "request"} * and {@link org.springframework.web.context.WebApplicationContext#SCOPE_SESSION "session"}, * based on this Scope SPI. * @@ -125,7 +125,7 @@ public interface Scope { /** * Resolve the contextual object for the given key, if any. - * E.g. the HttpServletRequest object for key "request". + * For example, the HttpServletRequest object for key "request". * @param key the contextual key * @return the corresponding object, or {@code null} if none found * @throws IllegalStateException if the underlying scope is not currently active diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/SetFactoryBean.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/SetFactoryBean.java index 756cc1cce2b9..8b30f8eb8f48 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/SetFactoryBean.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/SetFactoryBean.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2024 the original author 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,13 +16,13 @@ package org.springframework.beans.factory.config; -import java.util.LinkedHashSet; import java.util.Set; import org.springframework.beans.BeanUtils; import org.springframework.beans.TypeConverter; import org.springframework.core.ResolvableType; import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; /** * Simple factory for shared Set instances. Allows for central setup @@ -85,7 +85,7 @@ protected Set createInstance() { result = BeanUtils.instantiateClass(this.targetSetClass); } else { - result = new LinkedHashSet<>(this.sourceSet.size()); + result = CollectionUtils.newLinkedHashSet(this.sourceSet.size()); } Class valueType = null; if (this.targetSetClass != null) { diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/SingletonBeanRegistry.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/SingletonBeanRegistry.java index ee6d4a778cac..b1f9f876b425 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/SingletonBeanRegistry.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/SingletonBeanRegistry.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2015 the original author or authors. + * Copyright 2002-2024 the original author 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.beans.factory.config; +import java.util.function.Consumer; + import org.springframework.lang.Nullable; /** @@ -57,6 +59,17 @@ public interface SingletonBeanRegistry { */ void registerSingleton(String beanName, Object singletonObject); + /** + * Add a callback to be triggered when the specified singleton becomes available + * in the bean registry. + * @param beanName the name of the bean + * @param singletonConsumer a callback for reacting to the availability of the freshly + * registered/created singleton instance (intended for follow-up steps before the bean is + * actively used by other callers, not for modifying the given singleton instance itself) + * @since 6.2 + */ + void addSingletonCallback(String beanName, Consumer singletonConsumer); + /** * Return the (raw) singleton object registered under the given name. *

Only checks already instantiated singletons; does not return an Object @@ -129,7 +142,10 @@ public interface SingletonBeanRegistry { * Return the singleton mutex used by this registry (for external collaborators). * @return the mutex object (never {@code null}) * @since 4.2 + * @deprecated as of 6.2, in favor of lenient singleton locking + * (with this method returning an arbitrary object to lock on) */ + @Deprecated(since = "6.2") Object getSingletonMutex(); } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/SmartInstantiationAwareBeanPostProcessor.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/SmartInstantiationAwareBeanPostProcessor.java index cd54203aea54..86455b173c11 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/SmartInstantiationAwareBeanPostProcessor.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/SmartInstantiationAwareBeanPostProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,20 +87,20 @@ default Constructor[] determineCandidateConstructors(Class beanClass, Stri * typically for the purpose of resolving a circular reference. *

This callback gives post-processors a chance to expose a wrapper * early - that is, before the target bean instance is fully initialized. - * The exposed object should be equivalent to the what + * The exposed object should be equivalent to what * {@link #postProcessBeforeInitialization} / {@link #postProcessAfterInitialization} * would expose otherwise. Note that the object returned by this method will - * be used as bean reference unless the post-processor returns a different - * wrapper from said post-process callbacks. In other words: Those post-process + * be used as the bean reference unless the post-processor returns a different + * wrapper from said post-process callbacks. In other words, those post-process * callbacks may either eventually expose the same reference or alternatively * return the raw bean instance from those subsequent callbacks (if the wrapper * for the affected bean has been built for a call to this method already, - * it will be exposes as final bean reference by default). + * it will be exposed as the final bean reference by default). *

The default implementation returns the given {@code bean} as-is. * @param bean the raw bean instance * @param beanName the name of the bean - * @return the object to expose as bean reference - * (typically with the passed-in bean instance as default) + * @return the object to expose as the bean reference + * (typically the passed-in bean instance as default) * @throws org.springframework.beans.BeansException in case of errors */ default Object getEarlyBeanReference(Object bean, String beanName) throws BeansException { diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java index 1b1fae321279..472188fd389f 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java @@ -77,7 +77,7 @@ public abstract class YamlProcessor { * A map of document matchers allowing callers to selectively use only * some of the documents in a YAML resource. In YAML documents are * separated by {@code ---} lines, and each document is converted - * to properties before the match is made. E.g. + * to properties before the match is made. For example, *

 	 * environment: dev
 	 * url: https://dev.bar.com
diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlPropertiesFactoryBean.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlPropertiesFactoryBean.java
index 0c70f097d7c0..5c09abda686f 100644
--- a/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlPropertiesFactoryBean.java
+++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlPropertiesFactoryBean.java
@@ -32,7 +32,7 @@
  * has a lot of similar features.
  *
  * 

Note: All exposed values are of type {@code String} for access through - * the common {@link Properties#getProperty} method (e.g. in configuration property + * the common {@link Properties#getProperty} method (for example, in configuration property * resolution through {@link PropertyResourceConfigurer#setProperties(Properties)}). * If this is not desirable, use {@link YamlMapFactoryBean} instead. * 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 b098663f65ba..0d9a67cd04b6 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 @@ -701,6 +701,7 @@ else if (this.currentBeanDefinition != null) { } } + @SuppressWarnings("NullAway") private GroovyDynamicElementReader createDynamicElementReader(String namespace) { XmlReaderContext readerContext = this.groovyDslXmlBeanDefinitionReader.createReaderContext( new DescriptiveResource("Groovy")); diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/parsing/ComponentDefinition.java b/spring-beans/src/main/java/org/springframework/beans/factory/parsing/ComponentDefinition.java index 33ec279f9c24..692a5f515fb7 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/parsing/ComponentDefinition.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/parsing/ComponentDefinition.java @@ -28,7 +28,7 @@ * it is now possible for a single logical configuration entity, in this case an XML tag, to * create multiple {@link BeanDefinition BeanDefinitions} and {@link BeanReference RuntimeBeanReferences} * in order to provide more succinct configuration and greater convenience to end users. As such, it can - * no longer be assumed that each configuration entity (e.g. XML tag) maps to one {@link BeanDefinition}. + * no longer be assumed that each configuration entity (for example, XML tag) maps to one {@link BeanDefinition}. * For tool vendors and other users who wish to present visualization or support for configuring Spring * applications it is important that there is some mechanism in place to tie the {@link BeanDefinition BeanDefinitions} * in the {@link org.springframework.beans.factory.BeanFactory} back to the configuration data in a way diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractAutowireCapableBeanFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractAutowireCapableBeanFactory.java index ae9656818cc0..131c0313cfd6 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractAutowireCapableBeanFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractAutowireCapableBeanFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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 @@ import org.springframework.core.ResolvableType; import org.springframework.lang.Nullable; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.ReflectionUtils; import org.springframework.util.ReflectionUtils.MethodCallback; @@ -204,7 +205,7 @@ public InstantiationStrategy getInstantiationStrategy() { /** * Set the ParameterNameDiscoverer to use for resolving method parameter - * names if needed (e.g. for constructor names). + * names if needed (for example, for constructor names). *

Default is a {@link DefaultParameterNameDiscoverer}. */ public void setParameterNameDiscoverer(@Nullable ParameterNameDiscoverer parameterNameDiscoverer) { @@ -538,7 +539,7 @@ protected Object createBean(String beanName, RootBeanDefinition mbd, @Nullable O /** * Actually create the specified bean. Pre-creation processing has already happened - * at this point, e.g. checking {@code postProcessBeforeInstantiation} callbacks. + * at this point, for example, checking {@code postProcessBeforeInstantiation} callbacks. *

Differentiates between default bean instantiation, use of a * factory method, and autowiring a constructor. * @param beanName the name of the bean @@ -616,7 +617,7 @@ protected Object doCreateBean(String beanName, RootBeanDefinition mbd, @Nullable } else if (!this.allowRawInjectionDespiteWrapping && hasDependentBean(beanName)) { String[] dependentBeans = getDependentBeans(beanName); - Set actualDependentBeans = new LinkedHashSet<>(dependentBeans.length); + Set actualDependentBeans = CollectionUtils.newLinkedHashSet(dependentBeans.length); for (String dependentBean : dependentBeans) { if (!removeSingletonIfCreatedForTypeCheckOnly(dependentBean)) { actualDependentBeans.add(dependentBean); @@ -765,7 +766,7 @@ protected Class getTypeForFactoryMethod(String beanName, RootBeanDefinition m paramNames = pnd.getParameterNames(candidate); } } - Set usedValueHolders = new HashSet<>(paramTypes.length); + Set usedValueHolders = CollectionUtils.newHashSet(paramTypes.length); Object[] args = new Object[paramTypes.length]; for (int i = 0; i < args.length; i++) { ConstructorArgumentValues.ValueHolder valueHolder = cav.getArgumentValue( @@ -820,7 +821,7 @@ protected Class getTypeForFactoryMethod(String beanName, RootBeanDefinition m return cachedReturnType.resolve(); } catch (LinkageError err) { - // E.g. a NoClassDefFoundError for a generic method return type + // For example, a NoClassDefFoundError for a generic method return type if (logger.isDebugEnabled()) { logger.debug("Failed to resolve type for factory method of bean '" + beanName + "': " + (uniqueCandidate != null ? uniqueCandidate : commonType), err); @@ -990,7 +991,12 @@ protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, */ @Nullable private FactoryBean getSingletonFactoryBeanForTypeCheck(String beanName, RootBeanDefinition mbd) { - synchronized (getSingletonMutex()) { + boolean locked = this.singletonLock.tryLock(); + if (!locked) { + return null; + } + + try { BeanWrapper bw = this.factoryBeanInstanceCache.get(beanName); if (bw != null) { return (FactoryBean) bw.getWrappedInstance(); @@ -1013,6 +1019,7 @@ private FactoryBean getSingletonFactoryBeanForTypeCheck(String beanName, Root if (instance == null) { bw = createBeanInstance(beanName, mbd, null); instance = bw.getWrappedInstance(); + this.factoryBeanInstanceCache.put(beanName, bw); } } catch (UnsatisfiedDependencyException ex) { @@ -1037,11 +1044,10 @@ private FactoryBean getSingletonFactoryBeanForTypeCheck(String beanName, Root afterSingletonCreation(beanName); } - FactoryBean fb = getFactoryBean(beanName, instance); - if (bw != null) { - this.factoryBeanInstanceCache.put(beanName, bw); - } - return fb; + return getFactoryBean(beanName, instance); + } + finally { + this.singletonLock.unlock(); } } @@ -1903,7 +1909,7 @@ protected void invokeCustomInitMethod(String beanName, Object bean, RootBeanDefi if (logger.isTraceEnabled()) { logger.trace("Invoking init method '" + methodName + "' on bean with name '" + beanName + "'"); } - Method methodToInvoke = ClassUtils.getInterfaceMethodIfPossible(initMethod, beanClass); + Method methodToInvoke = ClassUtils.getPubliclyAccessibleMethodIfPossible(initMethod, beanClass); try { ReflectionUtils.makeAccessible(methodToInvoke); @@ -1932,10 +1938,8 @@ protected Object postProcessObjectFromFactoryBean(Object object, String beanName */ @Override protected void removeSingleton(String beanName) { - synchronized (getSingletonMutex()) { - super.removeSingleton(beanName); - this.factoryBeanInstanceCache.remove(beanName); - } + super.removeSingleton(beanName); + this.factoryBeanInstanceCache.remove(beanName); } /** @@ -1943,10 +1947,8 @@ protected void removeSingleton(String beanName) { */ @Override protected void clearSingletonCache() { - synchronized (getSingletonMutex()) { - super.clearSingletonCache(); - this.factoryBeanInstanceCache.clear(); - } + super.clearSingletonCache(); + this.factoryBeanInstanceCache.clear(); } /** 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 ebdadd211b6b..ab66ca7c8f39 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -173,6 +173,8 @@ public abstract class AbstractBeanDefinition extends BeanMetadataAttributeAccess private boolean abstractFlag = false; + private boolean backgroundInit = false; + @Nullable private Boolean lazyInit; @@ -185,8 +187,12 @@ public abstract class AbstractBeanDefinition extends BeanMetadataAttributeAccess private boolean autowireCandidate = true; + private boolean defaultCandidate = true; + private boolean primary = false; + private boolean fallback = false; + private final Map qualifiers = new LinkedHashMap<>(); @Nullable @@ -276,6 +282,7 @@ protected AbstractBeanDefinition(BeanDefinition original) { if (originalAbd.hasMethodOverrides()) { setMethodOverrides(new MethodOverrides(originalAbd.getMethodOverrides())); } + setBackgroundInit(originalAbd.isBackgroundInit()); Boolean lazyInit = originalAbd.getLazyInit(); if (lazyInit != null) { setLazyInit(lazyInit); @@ -284,7 +291,9 @@ protected AbstractBeanDefinition(BeanDefinition original) { setDependencyCheck(originalAbd.getDependencyCheck()); setDependsOn(originalAbd.getDependsOn()); setAutowireCandidate(originalAbd.isAutowireCandidate()); + setDefaultCandidate(originalAbd.isDefaultCandidate()); setPrimary(originalAbd.isPrimary()); + setFallback(originalAbd.isFallback()); copyQualifiersFrom(originalAbd); setInstanceSupplier(originalAbd.getInstanceSupplier()); setNonPublicAccessAllowed(originalAbd.isNonPublicAccessAllowed()); @@ -352,6 +361,7 @@ public void overrideFrom(BeanDefinition other) { if (otherAbd.hasMethodOverrides()) { getMethodOverrides().addOverrides(otherAbd.getMethodOverrides()); } + setBackgroundInit(otherAbd.isBackgroundInit()); Boolean lazyInit = otherAbd.getLazyInit(); if (lazyInit != null) { setLazyInit(lazyInit); @@ -360,7 +370,9 @@ public void overrideFrom(BeanDefinition other) { setDependencyCheck(otherAbd.getDependencyCheck()); setDependsOn(otherAbd.getDependsOn()); setAutowireCandidate(otherAbd.isAutowireCandidate()); + setDefaultCandidate(otherAbd.isDefaultCandidate()); setPrimary(otherAbd.isPrimary()); + setFallback(otherAbd.isFallback()); copyQualifiersFrom(otherAbd); setInstanceSupplier(otherAbd.getInstanceSupplier()); setNonPublicAccessAllowed(otherAbd.isNonPublicAccessAllowed()); @@ -404,7 +416,8 @@ public void applyDefaults(BeanDefinitionDefaults defaults) { /** - * Specify the bean class name of this bean definition. + * {@inheritDoc} + * @see #setBeanClass(Class) */ @Override public void setBeanClassName(@Nullable String beanClassName) { @@ -412,7 +425,8 @@ public void setBeanClassName(@Nullable String beanClassName) { } /** - * Return the current bean class name of this bean definition. + * {@inheritDoc} + * @see #getBeanClass() */ @Override @Nullable @@ -492,9 +506,8 @@ public Class resolveBeanClass(@Nullable ClassLoader classLoader) throws Class } /** - * Return a resolvable type for this bean definition. + * {@inheritDoc} *

This implementation delegates to {@link #getBeanClass()}. - * @since 5.2 */ @Override public ResolvableType getResolvableType() { @@ -502,7 +515,7 @@ public ResolvableType getResolvableType() { } /** - * Set the name of the target scope for the bean. + * {@inheritDoc} *

The default is singleton status, although this is only applied once * a bean definition becomes active in the containing factory. A bean * definition may eventually inherit its scope from a parent bean definition. @@ -517,7 +530,8 @@ public void setScope(@Nullable String scope) { } /** - * Return the name of the target scope for the bean. + * {@inheritDoc} + *

The default is {@link #SCOPE_DEFAULT}. */ @Override @Nullable @@ -526,9 +540,8 @@ public String getScope() { } /** - * Return whether this a Singleton, with a single shared instance - * returned from all calls. - * @see #SCOPE_SINGLETON + * {@inheritDoc} + *

The default is {@code true}. */ @Override public boolean isSingleton() { @@ -536,9 +549,8 @@ public boolean isSingleton() { } /** - * Return whether this a Prototype, with an independent instance - * returned for each call. - * @see #SCOPE_PROTOTYPE + * {@inheritDoc} + *

The default is {@code false}. */ @Override public boolean isPrototype() { @@ -548,16 +560,16 @@ public boolean isPrototype() { /** * Set if this bean is "abstract", i.e. not meant to be instantiated itself but * rather just serving as parent for concrete child bean definitions. - *

Default is "false". Specify true to tell the bean factory to not try to - * instantiate that particular bean in any case. + *

The default is "false". Specify {@code true} to tell the bean factory to + * not try to instantiate that particular bean in any case. */ public void setAbstract(boolean abstractFlag) { this.abstractFlag = abstractFlag; } /** - * Return whether this bean is "abstract", i.e. not meant to be instantiated - * itself but rather just serving as parent for concrete child bean definitions. + * {@inheritDoc} + *

The default is {@code false}. */ @Override public boolean isAbstract() { @@ -565,9 +577,39 @@ public boolean isAbstract() { } /** - * Set whether this bean should be lazily initialized. - *

If {@code false}, the bean will get instantiated on startup by bean - * factories that perform eager initialization of singletons. + * Specify the bootstrap mode for this bean: default is {@code false} for using + * the main pre-instantiation thread for non-lazy singleton beans and the caller + * thread for prototype beans. + *

Set this flag to {@code true} to allow for instantiating this bean on a + * background thread. For a non-lazy singleton, a background pre-instantiation + * thread can be used then, while still enforcing the completion at the end of + * {@link DefaultListableBeanFactory#preInstantiateSingletons()}. + * For a lazy singleton, a background pre-instantiation thread can be used as well + * - with completion allowed at a later point, enforcing it when actually accessed. + *

Note that this flag may be ignored by bean factories not set up for + * background bootstrapping, always applying single-threaded bootstrapping + * for non-lazy singleton beans. + * @since 6.2 + * @see #setLazyInit + * @see DefaultListableBeanFactory#setBootstrapExecutor + */ + public void setBackgroundInit(boolean backgroundInit) { + this.backgroundInit = backgroundInit; + } + + /** + * Return the bootstrap mode for this bean: default is {@code false} for using + * the main pre-instantiation thread for non-lazy singleton beans and the caller + * thread for prototype beans. + * @since 6.2 + */ + public boolean isBackgroundInit() { + return this.backgroundInit; + } + + /** + * {@inheritDoc} + *

The default is {@code false}. */ @Override public void setLazyInit(boolean lazyInit) { @@ -575,9 +617,8 @@ public void setLazyInit(boolean lazyInit) { } /** - * Return whether this bean should be lazily initialized, i.e. not - * eagerly instantiated on startup. Only applicable to a singleton bean. - * @return whether to apply lazy-init semantics ({@code false} by default) + * {@inheritDoc} + *

The default is {@code false}. */ @Override public boolean isLazyInit() { @@ -597,7 +638,7 @@ public Boolean getLazyInit() { /** * Set the autowire mode. This determines whether any automagical detection - * and setting of bean references will happen. Default is AUTOWIRE_NO + * and setting of bean references will happen. The default is AUTOWIRE_NO * which means there won't be convention-based autowiring by name or type * (however, there may still be explicit annotation-driven autowiring). * @param autowireMode the autowire mode to set. @@ -665,11 +706,8 @@ public int getDependencyCheck() { } /** - * Set the names of the beans that this bean depends on being initialized. - * The bean factory will guarantee that these beans get initialized first. - *

Note that dependencies are normally expressed through bean properties or - * constructor arguments. This property should just be necessary for other kinds - * of dependencies like statics (*ugh*) or database preparation on startup. + * {@inheritDoc} + *

The default is no beans to explicitly depend on. */ @Override public void setDependsOn(@Nullable String... dependsOn) { @@ -677,7 +715,8 @@ public void setDependsOn(@Nullable String... dependsOn) { } /** - * Return the bean names that this bean depends on. + * {@inheritDoc} + *

The default is no beans to explicitly depend on. */ @Override @Nullable @@ -686,11 +725,9 @@ public String[] getDependsOn() { } /** - * Set whether this bean is a candidate for getting autowired into some other bean. - *

Note that this flag is designed to only affect type-based autowiring. - * It does not affect explicit references by name, which will get resolved even - * if the specified bean is not marked as an autowire candidate. As a consequence, - * autowiring by name will nevertheless inject a bean if the name matches. + * {@inheritDoc} + *

The default is {@code true}, allowing injection by type at any injection point. + * Switch this to {@code false} in order to disable autowiring by type for this bean. * @see #AUTOWIRE_BY_TYPE * @see #AUTOWIRE_BY_NAME */ @@ -700,7 +737,8 @@ public void setAutowireCandidate(boolean autowireCandidate) { } /** - * Return whether this bean is a candidate for getting autowired into some other bean. + * {@inheritDoc} + *

The default is {@code true}. */ @Override public boolean isAutowireCandidate() { @@ -708,9 +746,32 @@ public boolean isAutowireCandidate() { } /** - * Set whether this bean is a primary autowire candidate. - *

If this value is {@code true} for exactly one bean among multiple - * matching candidates, it will serve as a tie-breaker. + * Set whether this bean is a candidate for getting autowired into some other + * bean based on the plain type, without any further indications such as a + * qualifier match. + *

The default is {@code true}, allowing injection by type at any injection point. + * Switch this to {@code false} in order to restrict injection by default, + * effectively enforcing an additional indication such as a qualifier match. + * @since 6.2 + */ + public void setDefaultCandidate(boolean defaultCandidate) { + this.defaultCandidate = defaultCandidate; + } + + /** + * Return whether this bean is a candidate for getting autowired into some other + * bean based on the plain type, without any further indications such as a + * qualifier match? + *

The default is {@code true}. + * @since 6.2 + */ + public boolean isDefaultCandidate() { + return this.defaultCandidate; + } + + /** + * {@inheritDoc} + *

The default is {@code false}. */ @Override public void setPrimary(boolean primary) { @@ -718,13 +779,32 @@ public void setPrimary(boolean primary) { } /** - * Return whether this bean is a primary autowire candidate. + * {@inheritDoc} + *

The default is {@code false}. */ @Override public boolean isPrimary() { return this.primary; } + /** + * {@inheritDoc} + *

The default is {@code false}. + */ + @Override + public void setFallback(boolean fallback) { + this.fallback = fallback; + } + + /** + * {@inheritDoc} + *

The default is {@code false}. + */ + @Override + public boolean isFallback() { + return this.fallback; + } + /** * Register a qualifier to be used for autowire candidate resolution, * keyed by the qualifier's type name. @@ -829,9 +909,8 @@ public boolean isLenientConstructorResolution() { } /** - * Specify the factory bean to use, if any. - * This the name of the bean to call the specified factory method on. - * @see #setFactoryMethodName + * {@inheritDoc} + * @see #setBeanClass */ @Override public void setFactoryBeanName(@Nullable String factoryBeanName) { @@ -839,7 +918,8 @@ public void setFactoryBeanName(@Nullable String factoryBeanName) { } /** - * Return the factory bean name, if any. + * {@inheritDoc} + * @see #getBeanClass() */ @Override @Nullable @@ -848,12 +928,10 @@ public String getFactoryBeanName() { } /** - * Specify a factory method, if any. This method will be invoked with - * constructor arguments, or with no arguments if none are specified. - * The method will be invoked on the specified factory bean, if any, - * or otherwise as a static method on the local bean class. - * @see #setFactoryBeanName - * @see #setBeanClassName + * {@inheritDoc} + * @see RootBeanDefinition#setUniqueFactoryMethodName + * @see RootBeanDefinition#setNonUniqueFactoryMethodName + * @see RootBeanDefinition#setResolvedFactoryMethod */ @Override public void setFactoryMethodName(@Nullable String factoryMethodName) { @@ -861,7 +939,8 @@ public void setFactoryMethodName(@Nullable String factoryMethodName) { } /** - * Return a factory method, if any. + * {@inheritDoc} + * @see RootBeanDefinition#getResolvedFactoryMethod() */ @Override @Nullable @@ -877,7 +956,8 @@ public void setConstructorArgumentValues(ConstructorArgumentValues constructorAr } /** - * Return constructor argument values for this bean (never {@code null}). + * {@inheritDoc} + * @see #setConstructorArgumentValues */ @Override public ConstructorArgumentValues getConstructorArgumentValues() { @@ -890,7 +970,8 @@ public ConstructorArgumentValues getConstructorArgumentValues() { } /** - * Return if there are constructor argument values defined for this bean. + * {@inheritDoc} + * @see #setConstructorArgumentValues */ @Override public boolean hasConstructorArgumentValues() { @@ -905,7 +986,8 @@ public void setPropertyValues(MutablePropertyValues propertyValues) { } /** - * Return property values for this bean (never {@code null}). + * {@inheritDoc} + * @see #setPropertyValues */ @Override public MutablePropertyValues getPropertyValues() { @@ -918,8 +1000,8 @@ public MutablePropertyValues getPropertyValues() { } /** - * Return if there are property values defined for this bean. - * @since 5.0.2 + * {@inheritDoc} + * @see #setPropertyValues */ @Override public boolean hasPropertyValues() { @@ -970,7 +1052,7 @@ public String[] getInitMethodNames() { } /** - * Set the name of the initializer method. + * {@inheritDoc} *

The default is {@code null} in which case there is no initializer method. * @see #setInitMethodNames */ @@ -980,7 +1062,8 @@ public void setInitMethodName(@Nullable String initMethodName) { } /** - * Return the name of the initializer method (the first one in case of multiple methods). + * {@inheritDoc} + *

Use the first one in case of multiple methods. */ @Override @Nullable @@ -992,7 +1075,7 @@ public String getInitMethodName() { * Specify whether the configured initializer method is the default. *

The default value is {@code true} for a locally specified init method * but switched to {@code false} for a shared setting in a defaults section - * (e.g. {@code bean init-method} versus {@code beans default-init-method} + * (for example, {@code bean init-method} versus {@code beans default-init-method} * level in XML) which might not apply to all contained bean definitions. * @see #setInitMethodName * @see #applyDefaults @@ -1029,7 +1112,7 @@ public String[] getDestroyMethodNames() { } /** - * Set the name of the destroy method. + * {@inheritDoc} *

The default is {@code null} in which case there is no destroy method. * @see #setDestroyMethodNames */ @@ -1039,7 +1122,8 @@ public void setDestroyMethodName(@Nullable String destroyMethodName) { } /** - * Return the name of the destroy method (the first one in case of multiple methods). + * {@inheritDoc} + *

Use the first one in case of multiple methods. */ @Override @Nullable @@ -1051,7 +1135,7 @@ public String getDestroyMethodName() { * Specify whether the configured destroy method is the default. *

The default value is {@code true} for a locally specified destroy method * but switched to {@code false} for a shared setting in a defaults section - * (e.g. {@code bean destroy-method} versus {@code beans default-destroy-method} + * (for example, {@code bean destroy-method} versus {@code beans default-destroy-method} * level in XML) which might not apply to all contained bean definitions. * @see #setDestroyMethodName * @see #applyDefaults @@ -1086,7 +1170,8 @@ public boolean isSynthetic() { } /** - * Set the role hint for this {@code BeanDefinition}. + * {@inheritDoc} + *

The default is {@link #ROLE_APPLICATION}. */ @Override public void setRole(int role) { @@ -1094,7 +1179,8 @@ public void setRole(int role) { } /** - * Return the role hint for this {@code BeanDefinition}. + * {@inheritDoc} + *

The default is {@link #ROLE_APPLICATION}. */ @Override public int getRole() { @@ -1102,7 +1188,8 @@ public int getRole() { } /** - * Set a human-readable description of this bean definition. + * {@inheritDoc} + *

The default is no description. */ @Override public void setDescription(@Nullable String description) { @@ -1110,7 +1197,8 @@ public void setDescription(@Nullable String description) { } /** - * Return a human-readable description of this bean definition. + * {@inheritDoc} + *

The default is no description. */ @Override @Nullable @@ -1143,8 +1231,8 @@ public void setResourceDescription(@Nullable String resourceDescription) { } /** - * Return a description of the resource that this bean definition - * came from (for the purpose of showing context in case of errors). + * {@inheritDoc} + * @see #setResourceDescription */ @Override @Nullable @@ -1153,17 +1241,15 @@ public String getResourceDescription() { } /** - * Set the originating (e.g. decorated) BeanDefinition, if any. + * Set the originating (for example, decorated) BeanDefinition, if any. */ public void setOriginatingBeanDefinition(BeanDefinition originatingBd) { this.resource = new BeanDefinitionResource(originatingBd); } /** - * Return the originating BeanDefinition, or {@code null} if none. - * Allows for retrieving the decorated bean definition, if any. - *

Note that this method returns the immediate originator. Iterate through the - * originator chain to find the original BeanDefinition as defined by the user. + * {@inheritDoc} + * @see #setOriginatingBeanDefinition */ @Override @Nullable @@ -1297,8 +1383,7 @@ public int hashCode() { @Override public String toString() { - StringBuilder sb = new StringBuilder("class ["); - sb.append(getBeanClassName()).append(']'); + StringBuilder sb = new StringBuilder("class=").append(getBeanClassName()); sb.append("; scope=").append(this.scope); sb.append("; abstract=").append(this.abstractFlag); sb.append("; lazyInit=").append(this.lazyInit); @@ -1306,6 +1391,7 @@ public String toString() { sb.append("; dependencyCheck=").append(this.dependencyCheck); sb.append("; autowireCandidate=").append(this.autowireCandidate); sb.append("; primary=").append(this.primary); + sb.append("; fallback=").append(this.fallback); sb.append("; factoryBeanName=").append(this.factoryBeanName); sb.append("; factoryMethodName=").append(this.factoryMethodName); sb.append("; initMethodNames=").append(Arrays.toString(this.initMethodNames)); diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.java index 824d86e29e22..6587479b8f55 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -74,6 +72,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; import org.springframework.util.StringValueResolver; @@ -138,6 +137,9 @@ public abstract class AbstractBeanFactory extends FactoryBeanRegistrySupport imp @Nullable private ConversionService conversionService; + /** Default PropertyEditorRegistrars to apply to the beans of this factory. */ + private final Set defaultEditorRegistrars = new LinkedHashSet<>(4); + /** Custom PropertyEditorRegistrars to apply to the beans of this factory. */ private final Set propertyEditorRegistrars = new LinkedHashSet<>(4); @@ -148,7 +150,7 @@ public abstract class AbstractBeanFactory extends FactoryBeanRegistrySupport imp @Nullable private TypeConverter typeConverter; - /** String resolvers to apply e.g. to annotation attribute values. */ + /** String resolvers to apply, for example, to annotation attribute values. */ private final List embeddedValueResolvers = new CopyOnWriteArrayList<>(); /** BeanPostProcessors to apply. */ @@ -161,14 +163,14 @@ public abstract class AbstractBeanFactory extends FactoryBeanRegistrySupport imp /** Map from scope identifier String to corresponding Scope. */ private final Map scopes = new LinkedHashMap<>(8); - /** Application startup metrics. **/ + /** Application startup metrics. */ private ApplicationStartup applicationStartup = ApplicationStartup.DEFAULT; /** Map from bean name to merged RootBeanDefinition. */ private final Map mergedBeanDefinitions = new ConcurrentHashMap<>(256); /** Names of beans that have already been created at least once. */ - private final Set alreadyCreated = Collections.newSetFromMap(new ConcurrentHashMap<>(256)); + private final Set alreadyCreated = ConcurrentHashMap.newKeySet(256); /** Names of beans that are currently in creation. */ private final ThreadLocal prototypesCurrentlyInCreation = @@ -884,7 +886,12 @@ public ConversionService getConversionService() { @Override public void addPropertyEditorRegistrar(PropertyEditorRegistrar registrar) { Assert.notNull(registrar, "PropertyEditorRegistrar must not be null"); - this.propertyEditorRegistrars.add(registrar); + if (registrar.overridesDefaultEditors()) { + this.defaultEditorRegistrars.add(registrar); + } + else { + this.propertyEditorRegistrars.add(registrar); + } } /** @@ -1115,6 +1122,7 @@ public void copyConfigurationFrom(ConfigurableBeanFactory otherFactory) { setBeanExpressionResolver(otherFactory.getBeanExpressionResolver()); setConversionService(otherFactory.getConversionService()); if (otherFactory instanceof AbstractBeanFactory otherAbstractFactory) { + this.defaultEditorRegistrars.addAll(otherAbstractFactory.defaultEditorRegistrars); this.propertyEditorRegistrars.addAll(otherAbstractFactory.propertyEditorRegistrars); this.customEditors.putAll(otherAbstractFactory.customEditors); this.typeConverter = otherAbstractFactory.typeConverter; @@ -1196,7 +1204,7 @@ protected void beforePrototypeCreation(String beanName) { this.prototypesCurrentlyInCreation.set(beanName); } else if (curVal instanceof String strValue) { - Set beanNameSet = new HashSet<>(2); + Set beanNameSet = CollectionUtils.newHashSet(2); beanNameSet.add(strValue); beanNameSet.add(beanName); this.prototypesCurrentlyInCreation.set(beanNameSet); @@ -1314,29 +1322,18 @@ protected void initBeanWrapper(BeanWrapper bw) { protected void registerCustomEditors(PropertyEditorRegistry registry) { if (registry instanceof PropertyEditorRegistrySupport registrySupport) { registrySupport.useConfigValueEditors(); + if (!this.defaultEditorRegistrars.isEmpty()) { + // Optimization: lazy overriding of default editors only when needed + registrySupport.setDefaultEditorRegistrar(new BeanFactoryDefaultEditorRegistrar()); + } } + else if (!this.defaultEditorRegistrars.isEmpty()) { + // Fallback: proactive overriding of default editors + applyEditorRegistrars(registry, this.defaultEditorRegistrars); + } + if (!this.propertyEditorRegistrars.isEmpty()) { - for (PropertyEditorRegistrar registrar : this.propertyEditorRegistrars) { - try { - registrar.registerCustomEditors(registry); - } - catch (BeanCreationException ex) { - Throwable rootCause = ex.getMostSpecificCause(); - if (rootCause instanceof BeanCurrentlyInCreationException bce) { - String bceBeanName = bce.getBeanName(); - if (bceBeanName != null && isCurrentlyInCreation(bceBeanName)) { - if (logger.isDebugEnabled()) { - logger.debug("PropertyEditorRegistrar [" + registrar.getClass().getName() + - "] failed because it tried to obtain currently created bean '" + - ex.getBeanName() + "': " + ex.getMessage()); - } - onSuppressedException(ex); - continue; - } - } - throw ex; - } - } + applyEditorRegistrars(registry, this.propertyEditorRegistrars); } if (!this.customEditors.isEmpty()) { this.customEditors.forEach((requiredType, editorClass) -> @@ -1344,6 +1341,29 @@ protected void registerCustomEditors(PropertyEditorRegistry registry) { } } + private void applyEditorRegistrars(PropertyEditorRegistry registry, Set registrars) { + for (PropertyEditorRegistrar registrar : registrars) { + try { + registrar.registerCustomEditors(registry); + } + catch (BeanCreationException ex) { + Throwable rootCause = ex.getMostSpecificCause(); + if (rootCause instanceof BeanCurrentlyInCreationException bce) { + String bceBeanName = bce.getBeanName(); + if (bceBeanName != null && isCurrentlyInCreation(bceBeanName)) { + if (logger.isDebugEnabled()) { + logger.debug("PropertyEditorRegistrar [" + registrar.getClass().getName() + + "] failed because it tried to obtain currently created bean '" + + ex.getBeanName() + "': " + ex.getMessage()); + } + onSuppressedException(ex); + return; + } + } + throw ex; + } + } + } /** * Return a merged RootBeanDefinition, traversing the parent bean definition @@ -1489,11 +1509,8 @@ private void copyRelevantMergedBeanDefinitionCaches(RootBeanDefinition previous, * @param mbd the merged bean definition to check * @param beanName the name of the bean * @param args the arguments for bean creation, if any - * @throws BeanDefinitionStoreException in case of validation failure */ - protected void checkMergedBeanDefinition(RootBeanDefinition mbd, String beanName, @Nullable Object[] args) - throws BeanDefinitionStoreException { - + protected void checkMergedBeanDefinition(RootBeanDefinition mbd, String beanName, @Nullable Object[] args) { if (mbd.isAbstract()) { throw new BeanIsAbstractException(beanName); } @@ -1515,7 +1532,7 @@ protected void clearMergedBeanDefinition(String beanName) { * Clear the merged bean definition cache, removing entries for beans * which are not considered eligible for full metadata caching yet. *

Typically triggered after changes to the original bean definitions, - * e.g. after applying a {@code BeanFactoryPostProcessor}. Note that metadata + * for example, after applying a {@code BeanFactoryPostProcessor}. Note that metadata * for beans which have already been created at this point will be kept around. * @since 4.2 */ @@ -1574,7 +1591,7 @@ private Class doResolveBeanClass(RootBeanDefinition mbd, Class... typesToM if (!ObjectUtils.isEmpty(typesToMatch)) { // When just doing type checks (i.e. not creating an actual instance yet), - // use the specified temporary class loader (e.g. in a weaving scenario). + // use the specified temporary class loader (for example, in a weaving scenario). ClassLoader tempClassLoader = getTempClassLoader(); if (tempClassLoader != null) { dynamicLoader = tempClassLoader; @@ -2099,4 +2116,20 @@ static class BeanPostProcessorCache { final List mergedDefinition = new ArrayList<>(); } + + /** + * {@link PropertyEditorRegistrar} that delegates to the bean factory's + * default registrars, adding exception handling for circular reference + * scenarios where an editor tries to refer back to the currently created bean. + * + * @since 6.2.3 + */ + class BeanFactoryDefaultEditorRegistrar implements PropertyEditorRegistrar { + + @Override + public void registerCustomEditors(PropertyEditorRegistry registry) { + applyEditorRegistrars(registry, defaultEditorRegistrars); + } + } + } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/AutowireCandidateResolver.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/AutowireCandidateResolver.java index e7eafe4158c8..c078572af5ab 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/AutowireCandidateResolver.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/AutowireCandidateResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +50,7 @@ default boolean isAutowireCandidate(BeanDefinitionHolder bdHolder, DependencyDes *

The default implementation checks {@link DependencyDescriptor#isRequired()}. * @param descriptor the descriptor for the target method parameter or field * @return whether the descriptor is marked as required or possibly indicating - * non-required status some other way (e.g. through a parameter annotation) + * non-required status some other way (for example, through a parameter annotation) * @since 5.0 * @see DependencyDescriptor#isRequired() */ @@ -72,6 +72,18 @@ default boolean hasQualifier(DependencyDescriptor descriptor) { return false; } + /** + * Determine whether a target bean name is suggested for the given dependency + * (typically - but not necessarily - declared with a single-value qualifier). + * @param descriptor the descriptor for the target method parameter or field + * @return the qualifier value, if any + * @since 6.2 + */ + @Nullable + default String getSuggestedName(DependencyDescriptor descriptor) { + return null; + } + /** * Determine whether a default value is suggested for the given dependency. *

The default implementation simply returns {@code null}. diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/AutowireUtils.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/AutowireUtils.java index 6baa1fd13880..246735bc41e1 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/AutowireUtils.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/AutowireUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author 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,7 +33,9 @@ import java.util.Set; import org.springframework.beans.BeanMetadataElement; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.ObjectFactory; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.beans.factory.config.TypedStringValue; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -122,7 +124,7 @@ public static boolean isSetterDefinedInInterface(PropertyDescriptor pd, Set req * the given {@code method} does not declare any {@linkplain * Method#getTypeParameters() formal type variables} *

  • the {@linkplain Method#getReturnType() standard return type}, if the - * target return type cannot be inferred (e.g., due to type erasure)
  • + * target return type cannot be inferred (for example, due to type erasure) *
  • {@code null}, if the length of the given arguments array is shorter * than the length of the {@linkplain * Method#getGenericParameterTypes() formal argument list} for the given @@ -182,8 +184,8 @@ public static Class resolveReturnTypeForFactoryMethod( Type[] methodParameterTypes = method.getGenericParameterTypes(); Assert.isTrue(args.length == methodParameterTypes.length, "Argument array does not match parameter count"); - // Ensure that the type variable (e.g., T) is declared directly on the method - // itself (e.g., via ), not on the enclosing class or interface. + // Ensure that the type variable (for example, T) is declared directly on the method + // itself (for example, via ), not on the enclosing class or interface. boolean locallyDeclaredTypeVariableMatchesReturnType = false; for (TypeVariable currentTypeVariable : declaredTypeVariables) { if (currentTypeVariable.equals(genericReturnType)) { @@ -259,6 +261,24 @@ else if (arg instanceof TypedStringValue typedValue) { return method.getReturnType(); } + /** + * Check the autowire-candidate status for the specified bean. + * @param beanFactory the bean factory + * @param beanName the name of the bean to check + * @return whether the specified bean qualifies as an autowire candidate + * @since 6.2.3 + * @see org.springframework.beans.factory.config.BeanDefinition#isAutowireCandidate() + */ + public static boolean isAutowireCandidate(ConfigurableBeanFactory beanFactory, String beanName) { + try { + return beanFactory.getMergedBeanDefinition(beanName).isAutowireCandidate(); + } + catch (NoSuchBeanDefinitionException ex) { + // A manually registered singleton instance not backed by a BeanDefinition. + return true; + } + } + /** * Reflective {@link InvocationHandler} for lazy access to the current target object. diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionBuilder.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionBuilder.java index 362737c9ee3a..d82d66bd75c0 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionBuilder.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ * @author Rod Johnson * @author Rob Harrop * @author Juergen Hoeller + * @author Yanming Zhou * @since 2.0 */ public final class BeanDefinitionBuilder { @@ -348,6 +349,15 @@ public BeanDefinitionBuilder setPrimary(boolean primary) { return this; } + /** + * Set whether this bean is a fallback autowire candidate. + * @since 6.2 + */ + public BeanDefinitionBuilder setFallback(boolean fallback) { + this.beanDefinition.setFallback(fallback); + return this; + } + /** * Set the role of this definition. */ diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionDefaults.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionDefaults.java index 2aecad8b437a..eb76dd9d13d8 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionDefaults.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionDefaults.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionOverrideException.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionOverrideException.java index f894298b151a..a815db479f92 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionOverrideException.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionOverrideException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,22 @@ public BeanDefinitionOverrideException( this.existingDefinition = existingDefinition; } + /** + * Create a new BeanDefinitionOverrideException for the given new and existing definition. + * @param beanName the name of the bean + * @param beanDefinition the newly registered bean definition + * @param existingDefinition the existing bean definition for the same name + * @param msg the detail message to include + * @since 6.2.1 + */ + public BeanDefinitionOverrideException( + String beanName, BeanDefinition beanDefinition, BeanDefinition existingDefinition, String msg) { + + super(beanDefinition.getResourceDescription(), beanName, msg); + this.beanDefinition = beanDefinition; + this.existingDefinition = existingDefinition; + } + /** * Return the description of the resource that the bean definition came from. diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionValueResolver.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionValueResolver.java index d2d0d947d34c..c3efcdcc0b2d 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionValueResolver.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/BeanDefinitionValueResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -466,7 +466,7 @@ private List resolveManagedList(Object argName, List ml) { * For each element in the managed set, resolve reference if necessary. */ private Set resolveManagedSet(Object argName, Set ms) { - Set resolved = new LinkedHashSet<>(ms.size()); + Set resolved = CollectionUtils.newLinkedHashSet(ms.size()); int i = 0; for (Object m : ms) { resolved.add(resolveValueIfNecessary(new KeyedArgName(argName, i), m)); diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/CglibSubclassingInstantiationStrategy.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/CglibSubclassingInstantiationStrategy.java index 551e0050a9ff..4c1f826f56a0 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/CglibSubclassingInstantiationStrategy.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/CglibSubclassingInstantiationStrategy.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -265,7 +265,7 @@ public Object intercept(Object obj, Method method, Object[] args, MethodProxy mp /** * CGLIB MethodInterceptor to override methods, replacing them with a call - * to a generic MethodReplacer. + * to a generic {@link MethodReplacer}. */ private static class ReplaceOverrideMethodInterceptor extends CglibIdentitySupport implements MethodInterceptor { @@ -276,13 +276,24 @@ public ReplaceOverrideMethodInterceptor(RootBeanDefinition beanDefinition, BeanF this.owner = owner; } + @Nullable @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy mp) throws Throwable { ReplaceOverride ro = (ReplaceOverride) getBeanDefinition().getMethodOverrides().getOverride(method); Assert.state(ro != null, "ReplaceOverride not found"); // TODO could cache if a singleton for minor performance optimization MethodReplacer mr = this.owner.getBean(ro.getMethodReplacerBeanName(), MethodReplacer.class); - return mr.reimplement(obj, method, args); + return processReturnType(method, mr.reimplement(obj, method, args)); + } + + @Nullable + private T processReturnType(Method method, @Nullable T returnValue) { + Class returnType = method.getReturnType(); + if (returnValue == null && returnType != void.class && returnType.isPrimitive()) { + throw new IllegalStateException( + "Null return value from MethodReplacer does not match primitive return type for: " + method); + } + return returnValue; } } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/ConstructorResolver.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/ConstructorResolver.java index 45b15772f014..39d0323db4a1 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/ConstructorResolver.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/ConstructorResolver.java @@ -63,6 +63,7 @@ import org.springframework.beans.factory.config.RuntimeBeanReference; import org.springframework.beans.factory.config.TypedStringValue; import org.springframework.core.CollectionFactory; +import org.springframework.core.KotlinDetector; import org.springframework.core.MethodParameter; import org.springframework.core.NamedThreadLocal; import org.springframework.core.ParameterNameDiscoverer; @@ -70,6 +71,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; import org.springframework.util.MethodInvoker; import org.springframework.util.ObjectUtils; import org.springframework.util.ReflectionUtils; @@ -129,6 +131,7 @@ public ConstructorResolver(AbstractAutowireCapableBeanFactory beanFactory) { * or {@code null} if none (-> use constructor argument values from bean definition) * @return a BeanWrapper for the new instance */ + @SuppressWarnings("NullAway") public BeanWrapper autowireConstructor(String beanName, RootBeanDefinition mbd, @Nullable Constructor[] chosenCtors, @Nullable Object[] explicitArgs) { @@ -390,6 +393,7 @@ private boolean isStaticCandidate(Method method, Class factoryClass) { * method, or {@code null} if none (-> use constructor argument values from bean definition) * @return a BeanWrapper for the new instance */ + @SuppressWarnings("NullAway") public BeanWrapper instantiateUsingFactoryMethod( String beanName, RootBeanDefinition mbd, @Nullable Object[] explicitArgs) { @@ -598,7 +602,7 @@ else if (factoryMethodToUse != null && typeDiffWeight == minTypeDiffWeight && } } else if (resolvedValues != null) { - Set valueHolders = new LinkedHashSet<>(resolvedValues.getArgumentCount()); + Set valueHolders = CollectionUtils.newLinkedHashSet(resolvedValues.getArgumentCount()); valueHolders.addAll(resolvedValues.getIndexedArgumentValues().values()); valueHolders.addAll(resolvedValues.getGenericArgumentValues()); for (ValueHolder value : valueHolders) { @@ -620,6 +624,11 @@ else if (void.class == factoryMethodToUse.getReturnType()) { "Invalid factory method '" + mbd.getFactoryMethodName() + "' on class [" + factoryClass.getName() + "]: needs to have a non-void return type!"); } + else if (KotlinDetector.isKotlinPresent() && KotlinDetector.isSuspendingFunction(factoryMethodToUse)) { + throw new BeanCreationException(mbd.getResourceDescription(), beanName, + "Invalid factory method '" + mbd.getFactoryMethodName() + "' on class [" + + factoryClass.getName() + "]: suspending functions are not supported!"); + } else if (ambiguousFactoryMethods != null) { throw new BeanCreationException(mbd.getResourceDescription(), beanName, "Ambiguous factory method matches found on class [" + factoryClass.getName() + "] " + @@ -909,7 +918,7 @@ Object resolveAutowiredArgument(DependencyDescriptor descriptor, Class paramT catch (NoSuchBeanDefinitionException ex) { if (fallback) { // Single constructor or factory method -> let's return an empty array/collection - // for e.g. a vararg or a non-null List/Set/Map parameter. + // for example, a vararg or a non-null List/Set/Map parameter. if (paramType.isArray()) { return Array.newInstance(paramType.componentType(), 0); } @@ -1232,8 +1241,8 @@ private Predicate valueOrCollection(ResolvableType valueType, /** * Return a {@link Predicate} for a parameter type that checks if its target * value is a {@link Class} and the value type is a {@link String}. This is - * a regular use cases where a {@link Class} is defined in the bean - * definition as an FQN. + * a regular use case where a {@link Class} is defined in the bean definition + * as a fully-qualified class name. * @param valueType the type of the value * @return a predicate to indicate a fallback match for a String to Class * parameter @@ -1314,7 +1323,7 @@ else if (ctors.length == 0) { // No public constructors -> check non-public ctors = clazz.getDeclaredConstructors(); if (ctors.length == 1) { - // A single non-public constructor, e.g. from a non-public record type + // A single non-public constructor, for example, from a non-public record type return ctors; } } @@ -1439,6 +1448,11 @@ public Object resolveShortcut(BeanFactory beanFactory) { String shortcut = this.shortcut; return (shortcut != null ? beanFactory.getBean(shortcut, getDependencyType()) : null); } + + @Override + public boolean usesStandardBeanLookup() { + return true; + } } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java index 67858633d774..54df257fe6f6 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,7 +37,10 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.function.Supplier; @@ -69,6 +72,7 @@ import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.config.DependencyDescriptor; import org.springframework.beans.factory.config.NamedBeanHolder; +import org.springframework.core.NamedThreadLocal; import org.springframework.core.OrderComparator; import org.springframework.core.Ordered; import org.springframework.core.ResolvableType; @@ -77,12 +81,14 @@ import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; import org.springframework.core.log.LogMessage; import org.springframework.core.metrics.StartupStep; +import org.springframework.lang.Contract; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.CompositeIterator; import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; /** @@ -146,11 +152,15 @@ public class DefaultListableBeanFactory extends AbstractAutowireCapableBeanFacto private String serializationId; /** Whether to allow re-registration of a different definition with the same name. */ - private boolean allowBeanDefinitionOverriding = true; + @Nullable + private Boolean allowBeanDefinitionOverriding; /** Whether to allow eager class loading even for lazy-init beans. */ private boolean allowEagerClassLoading = true; + @Nullable + private Executor bootstrapExecutor; + /** Optional OrderComparator for dependency Lists and arrays. */ @Nullable private Comparator dependencyComparator; @@ -167,6 +177,9 @@ public class DefaultListableBeanFactory extends AbstractAutowireCapableBeanFacto /** Map from bean name to merged BeanDefinitionHolder. */ private final Map mergedBeanDefinitionHolders = new ConcurrentHashMap<>(256); + /** Set of bean definition names with a primary marker. */ + private final Set primaryBeanNames = ConcurrentHashMap.newKeySet(16); + /** Map of singleton and non-singleton bean names, keyed by dependency type. */ private final Map, String[]> allBeanNamesByType = new ConcurrentHashMap<>(64); @@ -186,6 +199,11 @@ public class DefaultListableBeanFactory extends AbstractAutowireCapableBeanFacto /** Whether bean definition metadata may be cached for all beans. */ private volatile boolean configurationFrozen; + private volatile boolean preInstantiationPhase; + + private final NamedThreadLocal preInstantiationThread = + new NamedThreadLocal<>("Pre-instantiation thread marker"); + /** * Create a new DefaultListableBeanFactory. @@ -244,7 +262,7 @@ public void setAllowBeanDefinitionOverriding(boolean allowBeanDefinitionOverridi * @since 4.1.2 */ public boolean isAllowBeanDefinitionOverriding() { - return this.allowBeanDefinitionOverriding; + return !Boolean.FALSE.equals(this.allowBeanDefinitionOverriding); } /** @@ -270,6 +288,17 @@ public boolean isAllowEagerClassLoading() { return this.allowEagerClassLoading; } + @Override + public void setBootstrapExecutor(@Nullable Executor bootstrapExecutor) { + this.bootstrapExecutor = bootstrapExecutor; + } + + @Override + @Nullable + public Executor getBootstrapExecutor() { + return this.bootstrapExecutor; + } + /** * Set a {@link java.util.Comparator} for dependency Lists and arrays. * @since 4.0 @@ -316,10 +345,11 @@ public void copyConfigurationFrom(ConfigurableBeanFactory otherFactory) { if (otherFactory instanceof DefaultListableBeanFactory otherListableFactory) { this.allowBeanDefinitionOverriding = otherListableFactory.allowBeanDefinitionOverriding; this.allowEagerClassLoading = otherListableFactory.allowEagerClassLoading; + this.bootstrapExecutor = otherListableFactory.bootstrapExecutor; this.dependencyComparator = otherListableFactory.dependencyComparator; // A clone of the AutowireCandidateResolver since it is potentially BeanFactoryAware setAutowireCandidateResolver(otherListableFactory.getAutowireCandidateResolver().cloneIfNecessary()); - // Make resolvable dependencies (e.g. ResourceLoader) available here as well + // Make resolvable dependencies (for example, ResourceLoader) available here as well this.resolvableDependencies.putAll(otherListableFactory.resolvableDependencies); } } @@ -478,6 +508,32 @@ public Stream orderedStream() { Stream stream = matchingBeans.values().stream(); return stream.sorted(adaptOrderComparator(matchingBeans)); } + @SuppressWarnings("unchecked") + @Override + public Stream stream(Predicate> customFilter) { + return Arrays.stream(getBeanNamesForTypedStream(requiredType, allowEagerInit)) + .filter(name -> customFilter.test(getType(name))) + .map(name -> (T) getBean(name)) + .filter(bean -> !(bean instanceof NullBean)); + } + @SuppressWarnings("unchecked") + @Override + public Stream orderedStream(Predicate> customFilter) { + String[] beanNames = getBeanNamesForTypedStream(requiredType, allowEagerInit); + if (beanNames.length == 0) { + return Stream.empty(); + } + Map matchingBeans = CollectionUtils.newLinkedHashMap(beanNames.length); + for (String beanName : beanNames) { + if (customFilter.test(getType(beanName))) { + Object beanInstance = getBean(beanName); + if (!(beanInstance instanceof NullBean)) { + matchingBeans.put(beanName, (T) beanInstance); + } + } + } + return matchingBeans.values().stream().sorted(adaptOrderComparator(matchingBeans)); + } }; } @@ -742,7 +798,7 @@ public A findAnnotationOnBean( } if (containsBeanDefinition(beanName)) { RootBeanDefinition bd = getMergedLocalBeanDefinition(beanName); - // Check raw bean class, e.g. in case of a proxy. + // Check raw bean class, for example, in case of a proxy. if (bd.hasBeanClass() && bd.getFactoryMethodName() == null) { Class beanClass = bd.getBeanClass(); if (beanClass != beanType) { @@ -781,7 +837,7 @@ public Set findAllAnnotationsOnBean( } if (containsBeanDefinition(beanName)) { RootBeanDefinition bd = getMergedLocalBeanDefinition(beanName); - // Check raw bean class, e.g. in case of a proxy. + // Check raw bean class, for example, in case of a proxy. if (bd.hasBeanClass() && bd.getFactoryMethodName() == null) { Class beanClass = bd.getBeanClass(); if (beanClass != beanType) { @@ -951,6 +1007,33 @@ protected Object obtainInstanceFromSupplier(Supplier supplier, String beanNam return super.obtainInstanceFromSupplier(supplier, beanName, mbd); } + @Override + protected void checkMergedBeanDefinition(RootBeanDefinition mbd, String beanName, @Nullable Object[] args) { + super.checkMergedBeanDefinition(mbd, beanName, args); + + if (mbd.isBackgroundInit()) { + if (this.preInstantiationThread.get() == PreInstantiation.MAIN && getBootstrapExecutor() != null) { + throw new BeanCurrentlyInCreationException(beanName, "Bean marked for background " + + "initialization but requested in mainline thread - declare ObjectProvider " + + "or lazy injection point in dependent mainline beans"); + } + } + else { + // Bean intended to be initialized in main bootstrap thread + if (this.preInstantiationThread.get() == PreInstantiation.BACKGROUND) { + throw new BeanCurrentlyInCreationException(beanName, "Bean marked for mainline initialization " + + "but requested in background thread - enforce early instantiation in mainline thread " + + "through depends-on '" + beanName + "' declaration for dependent background beans"); + } + } + } + + @Override + @Nullable + protected Boolean isCurrentThreadAllowedToHoldSingletonLock() { + return (this.preInstantiationPhase ? this.preInstantiationThread.get() != PreInstantiation.BACKGROUND : null); + } + @Override public void preInstantiateSingletons() throws BeansException { if (logger.isTraceEnabled()) { @@ -962,24 +1045,38 @@ public void preInstantiateSingletons() throws BeansException { List beanNames = new ArrayList<>(this.beanDefinitionNames); // Trigger initialization of all non-lazy singleton beans... - for (String beanName : beanNames) { - RootBeanDefinition bd = getMergedLocalBeanDefinition(beanName); - if (!bd.isAbstract() && bd.isSingleton() && !bd.isLazyInit()) { - if (isFactoryBean(beanName)) { - Object bean = getBean(FACTORY_BEAN_PREFIX + beanName); - if (bean instanceof SmartFactoryBean smartFactoryBean && smartFactoryBean.isEagerInit()) { - getBean(beanName); + List> futures = new ArrayList<>(); + + this.preInstantiationPhase = true; + this.preInstantiationThread.set(PreInstantiation.MAIN); + try { + for (String beanName : beanNames) { + RootBeanDefinition mbd = getMergedLocalBeanDefinition(beanName); + if (!mbd.isAbstract() && mbd.isSingleton()) { + CompletableFuture future = preInstantiateSingleton(beanName, mbd); + if (future != null) { + futures.add(future); } } - else { - getBean(beanName); - } + } + } + finally { + this.preInstantiationThread.remove(); + this.preInstantiationPhase = false; + } + + if (!futures.isEmpty()) { + try { + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + } + catch (CompletionException ex) { + ReflectionUtils.rethrowRuntimeException(ex.getCause()); } } // Trigger post-initialization callback for all applicable beans... for (String beanName : beanNames) { - Object singletonInstance = getSingleton(beanName); + Object singletonInstance = getSingleton(beanName, false); if (singletonInstance instanceof SmartInitializingSingleton smartSingleton) { StartupStep smartInitialize = getApplicationStartup().start("spring.beans.smart-initialize") .tag("beanName", beanName); @@ -989,6 +1086,76 @@ public void preInstantiateSingletons() throws BeansException { } } + @Nullable + private CompletableFuture preInstantiateSingleton(String beanName, RootBeanDefinition mbd) { + if (mbd.isBackgroundInit()) { + Executor executor = getBootstrapExecutor(); + if (executor != null) { + String[] dependsOn = mbd.getDependsOn(); + if (dependsOn != null) { + for (String dep : dependsOn) { + getBean(dep); + } + } + CompletableFuture future = CompletableFuture.runAsync( + () -> instantiateSingletonInBackgroundThread(beanName), executor); + addSingletonFactory(beanName, () -> { + try { + future.join(); + } + catch (CompletionException ex) { + ReflectionUtils.rethrowRuntimeException(ex.getCause()); + } + return future; // not to be exposed, just to lead to ClassCastException in case of mismatch + }); + return (!mbd.isLazyInit() ? future : null); + } + else if (logger.isInfoEnabled()) { + logger.info("Bean '" + beanName + "' marked for background initialization " + + "without bootstrap executor configured - falling back to mainline initialization"); + } + } + + if (!mbd.isLazyInit()) { + try { + instantiateSingleton(beanName); + } + catch (BeanCurrentlyInCreationException ex) { + logger.info("Bean '" + beanName + "' marked for pre-instantiation (not lazy-init) " + + "but currently initialized by other thread - skipping it in mainline thread"); + } + } + return null; + } + + private void instantiateSingletonInBackgroundThread(String beanName) { + this.preInstantiationThread.set(PreInstantiation.BACKGROUND); + try { + instantiateSingleton(beanName); + } + catch (RuntimeException | Error ex) { + if (logger.isWarnEnabled()) { + logger.warn("Failed to instantiate singleton bean '" + beanName + "' in background thread", ex); + } + throw ex; + } + finally { + this.preInstantiationThread.remove(); + } + } + + private void instantiateSingleton(String beanName) { + if (isFactoryBean(beanName)) { + Object bean = getBean(FACTORY_BEAN_PREFIX + beanName); + if (bean instanceof SmartFactoryBean smartFactoryBean && smartFactoryBean.isEagerInit()) { + getBean(beanName); + } + } + else { + getBean(beanName); + } + } + //--------------------------------------------------------------------- // Implementation of BeanDefinitionRegistry interface @@ -1016,27 +1183,8 @@ public void registerBeanDefinition(String beanName, BeanDefinition beanDefinitio if (!isBeanDefinitionOverridable(beanName)) { throw new BeanDefinitionOverrideException(beanName, beanDefinition, existingDefinition); } - else if (existingDefinition.getRole() < beanDefinition.getRole()) { - // e.g. was ROLE_APPLICATION, now overriding with ROLE_SUPPORT or ROLE_INFRASTRUCTURE - if (logger.isInfoEnabled()) { - logger.info("Overriding user-defined bean definition for bean '" + beanName + - "' with a framework-generated bean definition: replacing [" + - existingDefinition + "] with [" + beanDefinition + "]"); - } - } - else if (!beanDefinition.equals(existingDefinition)) { - if (logger.isDebugEnabled()) { - logger.debug("Overriding bean definition for bean '" + beanName + - "' with a different definition: replacing [" + existingDefinition + - "] with [" + beanDefinition + "]"); - } - } else { - if (logger.isTraceEnabled()) { - logger.trace("Overriding bean definition for bean '" + beanName + - "' with an equivalent definition: replacing [" + existingDefinition + - "] with [" + beanDefinition + "]"); - } + logBeanDefinitionOverriding(beanName, beanDefinition, existingDefinition); } this.beanDefinitionMap.put(beanName, beanDefinition); } @@ -1055,6 +1203,11 @@ else if (!beanDefinition.equals(existingDefinition)) { } } else { + if (logger.isInfoEnabled()) { + logger.info("Removing alias '" + beanName + "' for bean '" + aliasedName + + "' due to registration of bean definition for bean '" + beanName + "': [" + + beanDefinition + "]"); + } removeAlias(beanName); } } @@ -1084,6 +1237,49 @@ else if (!beanDefinition.equals(existingDefinition)) { else if (isConfigurationFrozen()) { clearByTypeCache(); } + + // Cache a primary marker for the given bean. + if (beanDefinition.isPrimary()) { + this.primaryBeanNames.add(beanName); + } + } + + private void logBeanDefinitionOverriding(String beanName, BeanDefinition beanDefinition, + BeanDefinition existingDefinition) { + + boolean explicitBeanOverride = (this.allowBeanDefinitionOverriding != null); + if (existingDefinition.getRole() < beanDefinition.getRole()) { + // for example, was ROLE_APPLICATION, now overriding with ROLE_SUPPORT or ROLE_INFRASTRUCTURE + if (logger.isInfoEnabled()) { + logger.info("Overriding user-defined bean definition for bean '" + beanName + + "' with a framework-generated bean definition: replacing [" + + existingDefinition + "] with [" + beanDefinition + "]"); + } + } + else if (!beanDefinition.equals(existingDefinition)) { + if (explicitBeanOverride && logger.isInfoEnabled()) { + logger.info("Overriding bean definition for bean '" + beanName + + "' with a different definition: replacing [" + existingDefinition + + "] with [" + beanDefinition + "]"); + } + if (logger.isDebugEnabled()) { + logger.debug("Overriding bean definition for bean '" + beanName + + "' with a different definition: replacing [" + existingDefinition + + "] with [" + beanDefinition + "]"); + } + } + else { + if (explicitBeanOverride && logger.isInfoEnabled()) { + logger.info("Overriding bean definition for bean '" + beanName + + "' with an equivalent definition: replacing [" + existingDefinition + + "] with [" + beanDefinition + "]"); + } + if (logger.isTraceEnabled()) { + logger.trace("Overriding bean definition for bean '" + beanName + + "' with an equivalent definition: replacing [" + existingDefinition + + "] with [" + beanDefinition + "]"); + } + } } @Override @@ -1132,9 +1328,12 @@ protected void resetBeanDefinition(String beanName) { // Remove corresponding bean from singleton cache, if any. Shouldn't usually // be necessary, rather just meant for overriding a context's default beans - // (e.g. the default StaticMessageSource in a StaticApplicationContext). + // (for example, the default StaticMessageSource in a StaticApplicationContext). destroySingleton(beanName); + // Remove a cached primary marker for the given bean. + this.primaryBeanNames.remove(beanName); + // Notify all post-processors that the specified bean definition has been reset. for (MergedBeanDefinitionPostProcessor processor : getBeanPostProcessorCache().mergedDefinition) { processor.resetBeanDefinition(beanName); @@ -1354,12 +1553,13 @@ else if (descriptor.supportsLazyResolution()) { } @Nullable + @SuppressWarnings("NullAway") public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable String beanName, @Nullable Set autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException { InjectionPoint previousInjectionPoint = ConstructorResolver.setCurrentInjectionPoint(descriptor); try { - // Step 1: pre-resolved shortcut for single bean match, e.g. from @Autowired + // Step 1: pre-resolved shortcut for single bean match, for example, from @Autowired Object shortcut = descriptor.resolveShortcut(this); if (shortcut != null) { return shortcut; @@ -1367,7 +1567,7 @@ public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable Str Class type = descriptor.getDependencyType(); - // Step 2: pre-defined value or expression, e.g. from @Value + // Step 2: pre-defined value or expression, for example, from @Value Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor); if (value != null) { if (value instanceof String strValue) { @@ -1388,15 +1588,36 @@ public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable Str } } - // Step 3a: multiple beans as stream / array / standard collection / plain map + // Step 3: shortcut for declared dependency name or qualifier-suggested name matching target bean name + if (descriptor.usesStandardBeanLookup()) { + String dependencyName = descriptor.getDependencyName(); + if (dependencyName == null || !containsBean(dependencyName)) { + String suggestedName = getAutowireCandidateResolver().getSuggestedName(descriptor); + dependencyName = (suggestedName != null && containsBean(suggestedName) ? suggestedName : null); + } + if (dependencyName != null) { + dependencyName = canonicalName(dependencyName); // dependency name can be alias of target name + if (isTypeMatch(dependencyName, type) && isAutowireCandidate(dependencyName, descriptor) && + !isFallback(dependencyName) && !hasPrimaryConflict(dependencyName, type) && + !isSelfReference(beanName, dependencyName)) { + if (autowiredBeanNames != null) { + autowiredBeanNames.add(dependencyName); + } + Object dependencyBean = getBean(dependencyName); + return resolveInstance(dependencyBean, descriptor, type, dependencyName); + } + } + } + + // Step 4a: multiple beans as stream / array / standard collection / plain map Object multipleBeans = resolveMultipleBeans(descriptor, beanName, autowiredBeanNames, typeConverter); if (multipleBeans != null) { return multipleBeans; } - // Step 3b: direct bean matches, possibly direct beans of type Collection / Map + // Step 4b: direct bean matches, possibly direct beans of type Collection / Map Map matchingBeans = findAutowireCandidates(beanName, type, descriptor); if (matchingBeans.isEmpty()) { - // Step 3c (fallback): custom Collection / Map declarations for collecting multiple beans + // Step 4c (fallback): custom Collection / Map declarations for collecting multiple beans multipleBeans = resolveMultipleBeansFallback(descriptor, beanName, autowiredBeanNames, typeConverter); if (multipleBeans != null) { return multipleBeans; @@ -1411,7 +1632,7 @@ public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable Str String autowiredBeanName; Object instanceCandidate; - // Step 4: determine single candidate + // Step 5: determine single candidate if (matchingBeans.size() > 1) { autowiredBeanName = determineAutowireCandidate(matchingBeans, descriptor); if (autowiredBeanName == null) { @@ -1435,31 +1656,37 @@ public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable Str instanceCandidate = entry.getValue(); } - // Step 5: validate single result + // Step 6: validate single result if (autowiredBeanNames != null) { autowiredBeanNames.add(autowiredBeanName); } if (instanceCandidate instanceof Class) { instanceCandidate = descriptor.resolveCandidate(autowiredBeanName, type, this); } - Object result = instanceCandidate; - if (result instanceof NullBean) { - if (isRequired(descriptor)) { - // Raise exception if null encountered for required injection point - raiseNoMatchingBeanFound(type, descriptor.getResolvableType(), descriptor); - } - result = null; - } - if (!ClassUtils.isAssignableValue(type, result)) { - throw new BeanNotOfRequiredTypeException(autowiredBeanName, type, instanceCandidate.getClass()); - } - return result; + return resolveInstance(instanceCandidate, descriptor, type, autowiredBeanName); } finally { ConstructorResolver.setCurrentInjectionPoint(previousInjectionPoint); } } + @Nullable + private Object resolveInstance(Object candidate, DependencyDescriptor descriptor, Class type, String name) { + Object result = candidate; + if (result instanceof NullBean) { + // Raise exception if null encountered for required injection point + if (isRequired(descriptor)) { + raiseNoMatchingBeanFound(type, descriptor.getResolvableType(), descriptor); + } + result = null; + } + if (!ClassUtils.isAssignableValue(type, result)) { + throw new BeanNotOfRequiredTypeException(name, type, candidate.getClass()); + } + return result; + + } + @Nullable private Object resolveMultipleBeans(DependencyDescriptor descriptor, @Nullable String beanName, @Nullable Set autowiredBeanNames, @Nullable TypeConverter typeConverter) { @@ -1691,8 +1918,8 @@ private void addCandidateEntry(Map candidates, String candidateN candidates.put(candidateName, beanInstance); } } - else if (containsSingleton(candidateName) || (descriptor instanceof StreamDependencyDescriptor streamDescriptor && - streamDescriptor.isOrdered())) { + else if (containsSingleton(candidateName) || + (descriptor instanceof StreamDependencyDescriptor streamDescriptor && streamDescriptor.isOrdered())) { Object beanInstance = descriptor.resolveCandidate(candidateName, requiredType, this); candidates.put(candidateName, (beanInstance instanceof NullBean ? null : beanInstance)); } @@ -1712,20 +1939,39 @@ else if (containsSingleton(candidateName) || (descriptor instanceof StreamDepend @Nullable protected String determineAutowireCandidate(Map candidates, DependencyDescriptor descriptor) { Class requiredType = descriptor.getDependencyType(); + // Step 1: check primary candidate String primaryCandidate = determinePrimaryCandidate(candidates, requiredType); if (primaryCandidate != null) { return primaryCandidate; } + // Step 2a: match bean name against declared dependency name + String dependencyName = descriptor.getDependencyName(); + if (dependencyName != null) { + for (String beanName : candidates.keySet()) { + if (matchesBeanName(beanName, dependencyName)) { + return beanName; + } + } + } + // Step 2b: match bean name against qualifier-suggested name + String suggestedName = getAutowireCandidateResolver().getSuggestedName(descriptor); + if (suggestedName != null) { + for (String beanName : candidates.keySet()) { + if (matchesBeanName(beanName, suggestedName)) { + return beanName; + } + } + } + // Step 3: check highest priority candidate String priorityCandidate = determineHighestPriorityCandidate(candidates, requiredType); if (priorityCandidate != null) { return priorityCandidate; } - // Fallback: pick directly registered dependency or qualified bean name match + // Step 4: pick directly registered dependency for (Map.Entry entry : candidates.entrySet()) { String candidateName = entry.getKey(); Object beanInstance = entry.getValue(); - if ((beanInstance != null && this.resolvableDependencies.containsValue(beanInstance)) || - matchesBeanName(candidateName, descriptor.getDependencyName())) { + if (beanInstance != null && this.resolvableDependencies.containsValue(beanInstance)) { return candidateName; } } @@ -1743,6 +1989,7 @@ protected String determineAutowireCandidate(Map candidates, Depe @Nullable protected String determinePrimaryCandidate(Map candidates, Class requiredType) { String primaryBeanName = null; + // First pass: identify unique primary candidate for (Map.Entry entry : candidates.entrySet()) { String candidateBeanName = entry.getKey(); Object beanInstance = entry.getValue(); @@ -1750,7 +1997,7 @@ protected String determinePrimaryCandidate(Map candidates, Class if (primaryBeanName != null) { boolean candidateLocal = containsBeanDefinition(candidateBeanName); boolean primaryLocal = containsBeanDefinition(primaryBeanName); - if (candidateLocal && primaryLocal) { + if (candidateLocal == primaryLocal) { throw new NoUniqueBeanDefinitionException(requiredType, candidates.size(), "more than one 'primary' bean found among candidates: " + candidates.keySet()); } @@ -1763,6 +2010,17 @@ else if (candidateLocal) { } } } + // Second pass: identify unique non-fallback candidate + if (primaryBeanName == null) { + for (String candidateBeanName : candidates.keySet()) { + if (!isFallback(candidateBeanName)) { + if (primaryBeanName != null) { + return null; + } + primaryBeanName = candidateBeanName; + } + } + } return primaryBeanName; } @@ -1834,6 +2092,21 @@ protected boolean isPrimary(String beanName, Object beanInstance) { parent.isPrimary(transformedBeanName, beanInstance)); } + /** + * Return whether the bean definition for the given bean name has been + * marked as a fallback bean. + * @param beanName the name of the bean + * @since 6.2 + */ + private boolean isFallback(String beanName) { + String transformedBeanName = transformedBeanName(beanName); + if (containsBeanDefinition(transformedBeanName)) { + return getMergedLocalBeanDefinition(transformedBeanName).isFallback(); + } + return (getParentBeanFactory() instanceof DefaultListableBeanFactory parent && + parent.isFallback(transformedBeanName)); + } + /** * Return the priority assigned for the given bean instance by * the {@code jakarta.annotation.Priority} annotation. @@ -1869,12 +2142,27 @@ protected boolean matchesBeanName(String beanName, @Nullable String candidateNam * i.e. whether the candidate points back to the original bean or to a factory method * on the original bean. */ + @Contract("null, _ -> false;_, null -> false;") private boolean isSelfReference(@Nullable String beanName, @Nullable String candidateName) { return (beanName != null && candidateName != null && (beanName.equals(candidateName) || (containsBeanDefinition(candidateName) && beanName.equals(getMergedLocalBeanDefinition(candidateName).getFactoryBeanName())))); } + /** + * Determine whether there is a primary bean registered for the given dependency type, + * not matching the given bean name. + */ + private boolean hasPrimaryConflict(String beanName, Class dependencyType) { + for (String candidate : this.primaryBeanNames) { + if (isTypeMatch(candidate, dependencyType) && !candidate.equals(beanName)) { + return true; + } + } + return (getParentBeanFactory() instanceof DefaultListableBeanFactory parent && + parent.hasPrimaryConflict(beanName, dependencyType)); + } + /** * Raise a NoSuchBeanDefinitionException or BeanNotOfRequiredTypeException * for an unresolvable dependency. @@ -1935,6 +2223,10 @@ public Object resolveCandidate(String beanName, Class requiredType, BeanFacto return (!ObjectUtils.isEmpty(args) ? beanFactory.getBean(beanName, args) : super.resolveCandidate(beanName, requiredType, beanFactory)); } + @Override + public boolean usesStandardBeanLookup() { + return ObjectUtils.isEmpty(args); + } }; Object result = doResolveDependency(descriptorToUse, beanName, null, null); return (result instanceof Optional optional ? optional : Optional.ofNullable(result)); @@ -2016,6 +2308,11 @@ public NestedDependencyDescriptor(DependencyDescriptor original) { super(original); increaseNestingLevel(); } + + @Override + public boolean usesStandardBeanLookup() { + return true; + } } @@ -2117,6 +2414,10 @@ public Object getIfAvailable() throws BeansException { public boolean isRequired() { return false; } + @Override + public boolean usesStandardBeanLookup() { + return true; + } }; return doResolveDependency(descriptorToUse, this.beanName, null, null); } @@ -2149,6 +2450,10 @@ public boolean isRequired() { return false; } @Override + public boolean usesStandardBeanLookup() { + return true; + } + @Override @Nullable public Object resolveNotUnique(ResolvableType type, Map matchingBeans) { return null; @@ -2207,6 +2512,34 @@ private Stream resolveStream(boolean ordered) { Object result = doResolveDependency(descriptorToUse, this.beanName, null, null); return (result instanceof Stream stream ? stream : Stream.of(result)); } + + @Override + public Stream stream(Predicate> customFilter) { + return Arrays.stream(getBeanNamesForTypedStream(this.descriptor.getResolvableType(), true)) + .filter(name -> AutowireUtils.isAutowireCandidate(DefaultListableBeanFactory.this, name)) + .filter(name -> customFilter.test(getType(name))) + .map(name -> getBean(name)) + .filter(bean -> !(bean instanceof NullBean)); + } + + @Override + public Stream orderedStream(Predicate> customFilter) { + String[] beanNames = getBeanNamesForTypedStream(this.descriptor.getResolvableType(), true); + if (beanNames.length == 0) { + return Stream.empty(); + } + Map matchingBeans = CollectionUtils.newLinkedHashMap(beanNames.length); + for (String beanName : beanNames) { + if (AutowireUtils.isAutowireCandidate(DefaultListableBeanFactory.this, beanName) && + customFilter.test(getType(beanName))) { + Object beanInstance = getBean(beanName); + if (!(beanInstance instanceof NullBean)) { + matchingBeans.put(beanName, beanInstance); + } + } + } + return matchingBeans.values().stream().sorted(adaptOrderComparator(matchingBeans)); + } } @@ -2289,4 +2622,10 @@ public Object getOrderSource(Object obj) { } } + + private enum PreInstantiation { + + MAIN, BACKGROUND + } + } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultSingletonBeanRegistry.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultSingletonBeanRegistry.java index 81e442404973..835be3e501fa 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultSingletonBeanRegistry.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultSingletonBeanRegistry.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,7 +17,6 @@ package org.springframework.beans.factory.support; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; @@ -25,6 +24,10 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; import org.springframework.beans.factory.BeanCreationException; import org.springframework.beans.factory.BeanCreationNotAllowedException; @@ -74,33 +77,46 @@ public class DefaultSingletonBeanRegistry extends SimpleAliasRegistry implements private static final int SUPPRESSED_EXCEPTIONS_LIMIT = 100; + /** Common lock for singleton creation. */ + final Lock singletonLock = new ReentrantLock(); + /** Cache of singleton objects: bean name to bean instance. */ private final Map singletonObjects = new ConcurrentHashMap<>(256); - /** Cache of singleton factories: bean name to ObjectFactory. */ - private final Map> singletonFactories = new HashMap<>(16); + /** Creation-time registry of singleton factories: bean name to ObjectFactory. */ + private final Map> singletonFactories = new ConcurrentHashMap<>(16); + + /** Custom callbacks for singleton creation/registration. */ + private final Map> singletonCallbacks = new ConcurrentHashMap<>(16); /** Cache of early singleton objects: bean name to bean instance. */ private final Map earlySingletonObjects = new ConcurrentHashMap<>(16); /** Set of registered singletons, containing the bean names in registration order. */ - private final Set registeredSingletons = new LinkedHashSet<>(256); + private final Set registeredSingletons = Collections.synchronizedSet(new LinkedHashSet<>(256)); /** Names of beans that are currently in creation. */ - private final Set singletonsCurrentlyInCreation = - Collections.newSetFromMap(new ConcurrentHashMap<>(16)); + private final Set singletonsCurrentlyInCreation = ConcurrentHashMap.newKeySet(16); /** Names of beans currently excluded from in creation checks. */ - private final Set inCreationCheckExclusions = - Collections.newSetFromMap(new ConcurrentHashMap<>(16)); + private final Set inCreationCheckExclusions = ConcurrentHashMap.newKeySet(16); + + /** Specific lock for lenient creation tracking. */ + private final Lock lenientCreationLock = new ReentrantLock(); + + /** Specific lock condition for lenient creation tracking. */ + private final Condition lenientCreationFinished = this.lenientCreationLock.newCondition(); + + /** Names of beans that are currently in lenient creation. */ + private final Set singletonsInLenientCreation = new HashSet<>(); + + /** Flag that indicates whether we're currently within destroySingletons. */ + private volatile boolean singletonsCurrentlyInDestruction = false; /** Collection of suppressed Exceptions, available for associating related causes. */ @Nullable private Set suppressedExceptions; - /** Flag that indicates whether we're currently within destroySingletons. */ - private boolean singletonsCurrentlyInDestruction = false; - /** Disposable bean instances: bean name to disposable instance. */ private final Map disposableBeans = new LinkedHashMap<>(); @@ -118,48 +134,55 @@ public class DefaultSingletonBeanRegistry extends SimpleAliasRegistry implements public void registerSingleton(String beanName, Object singletonObject) throws IllegalStateException { Assert.notNull(beanName, "Bean name must not be null"); Assert.notNull(singletonObject, "Singleton object must not be null"); - synchronized (this.singletonObjects) { - Object oldObject = this.singletonObjects.get(beanName); - if (oldObject != null) { - throw new IllegalStateException("Could not register object [" + singletonObject + - "] under bean name '" + beanName + "': there is already object [" + oldObject + "] bound"); - } + this.singletonLock.lock(); + try { addSingleton(beanName, singletonObject); } + finally { + this.singletonLock.unlock(); + } } /** - * Add the given singleton object to the singleton cache of this factory. - *

    To be called for eager registration of singletons. + * Add the given singleton object to the singleton registry. + *

    To be called for exposure of freshly registered/created singletons. * @param beanName the name of the bean * @param singletonObject the singleton object */ protected void addSingleton(String beanName, Object singletonObject) { - synchronized (this.singletonObjects) { - this.singletonObjects.put(beanName, singletonObject); - this.singletonFactories.remove(beanName); - this.earlySingletonObjects.remove(beanName); - this.registeredSingletons.add(beanName); + Object oldObject = this.singletonObjects.putIfAbsent(beanName, singletonObject); + if (oldObject != null) { + throw new IllegalStateException("Could not register object [" + singletonObject + + "] under bean name '" + beanName + "': there is already object [" + oldObject + "] bound"); + } + this.singletonFactories.remove(beanName); + this.earlySingletonObjects.remove(beanName); + this.registeredSingletons.add(beanName); + + Consumer callback = this.singletonCallbacks.get(beanName); + if (callback != null) { + callback.accept(singletonObject); } } /** * Add the given singleton factory for building the specified singleton * if necessary. - *

    To be called for eager registration of singletons, e.g. to be able to + *

    To be called for early exposure purposes, for example, to be able to * resolve circular references. * @param beanName the name of the bean * @param singletonFactory the factory for the singleton object */ protected void addSingletonFactory(String beanName, ObjectFactory singletonFactory) { Assert.notNull(singletonFactory, "Singleton factory must not be null"); - synchronized (this.singletonObjects) { - if (!this.singletonObjects.containsKey(beanName)) { - this.singletonFactories.put(beanName, singletonFactory); - this.earlySingletonObjects.remove(beanName); - this.registeredSingletons.add(beanName); - } - } + this.singletonFactories.put(beanName, singletonFactory); + this.earlySingletonObjects.remove(beanName); + this.registeredSingletons.add(beanName); + } + + @Override + public void addSingletonCallback(String beanName, Consumer singletonConsumer) { + this.singletonCallbacks.put(beanName, singletonConsumer); } @Override @@ -178,13 +201,17 @@ public Object getSingleton(String beanName) { */ @Nullable protected Object getSingleton(String beanName, boolean allowEarlyReference) { - // Quick check for existing instance without full singleton lock + // Quick check for existing instance without full singleton lock. Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) { singletonObject = this.earlySingletonObjects.get(beanName); if (singletonObject == null && allowEarlyReference) { - synchronized (this.singletonObjects) { - // Consistent creation of early reference within full singleton lock + if (!this.singletonLock.tryLock()) { + // Avoid early singleton inference outside of original creation thread. + return null; + } + try { + // Consistent creation of early reference within full singleton lock. singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null) { singletonObject = this.earlySingletonObjects.get(beanName); @@ -192,12 +219,20 @@ protected Object getSingleton(String beanName, boolean allowEarlyReference) { ObjectFactory singletonFactory = this.singletonFactories.get(beanName); if (singletonFactory != null) { singletonObject = singletonFactory.getObject(); - this.earlySingletonObjects.put(beanName, singletonObject); - this.singletonFactories.remove(beanName); + // Singleton could have been added or removed in the meantime. + if (this.singletonFactories.remove(beanName) != null) { + this.earlySingletonObjects.put(beanName, singletonObject); + } + else { + singletonObject = this.singletonObjects.get(beanName); + } } } } } + finally { + this.singletonLock.unlock(); + } } } return singletonObject; @@ -211,11 +246,50 @@ protected Object getSingleton(String beanName, boolean allowEarlyReference) { * with, if necessary * @return the registered singleton object */ + @SuppressWarnings("NullAway") public Object getSingleton(String beanName, ObjectFactory singletonFactory) { Assert.notNull(beanName, "Bean name must not be null"); - synchronized (this.singletonObjects) { + + Boolean lockFlag = isCurrentThreadAllowedToHoldSingletonLock(); + boolean acquireLock = !Boolean.FALSE.equals(lockFlag); + boolean locked = (acquireLock && this.singletonLock.tryLock()); + boolean lenient = false; + try { Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null) { + if (acquireLock && !locked) { + if (Boolean.TRUE.equals(lockFlag)) { + // Another thread is busy in a singleton factory callback, potentially blocked. + // Fallback as of 6.2: process given singleton bean outside of singleton lock. + // Thread-safe exposure is still guaranteed, there is just a risk of collisions + // when triggering creation of other beans as dependencies of the current bean. + if (logger.isInfoEnabled()) { + logger.info("Creating singleton bean '" + beanName + "' in thread \"" + + Thread.currentThread().getName() + "\" while other thread holds " + + "singleton lock for other beans " + this.singletonsCurrentlyInCreation); + } + lenient = true; + this.lenientCreationLock.lock(); + try { + this.singletonsInLenientCreation.add(beanName); + } + finally { + this.lenientCreationLock.unlock(); + } + } + else { + // No specific locking indication (outside a coordinated bootstrap) and + // singleton lock currently held by some other creation method -> wait. + this.singletonLock.lock(); + locked = true; + // Singleton object might have possibly appeared in the meantime. + singletonObject = this.singletonObjects.get(beanName); + if (singletonObject != null) { + return singletonObject; + } + } + } + if (this.singletonsCurrentlyInDestruction) { throw new BeanCreationNotAllowedException(beanName, "Singleton bean creation not allowed while singletons of this factory are in destruction " + @@ -224,9 +298,47 @@ public Object getSingleton(String beanName, ObjectFactory singletonFactory) { if (logger.isDebugEnabled()) { logger.debug("Creating shared instance of singleton bean '" + beanName + "'"); } - beforeSingletonCreation(beanName); + + try { + beforeSingletonCreation(beanName); + } + catch (BeanCurrentlyInCreationException ex) { + this.lenientCreationLock.lock(); + try { + while ((singletonObject = this.singletonObjects.get(beanName)) == null) { + if (!this.singletonsInLenientCreation.contains(beanName)) { + break; + } + try { + this.lenientCreationFinished.await(); + } + catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + } + finally { + this.lenientCreationLock.unlock(); + } + if (singletonObject != null) { + return singletonObject; + } + if (locked) { + throw ex; + } + // Try late locking for waiting on specific bean to be finished. + this.singletonLock.lock(); + locked = true; + // Singleton object should have appeared in the meantime. + singletonObject = this.singletonObjects.get(beanName); + if (singletonObject != null) { + return singletonObject; + } + beforeSingletonCreation(beanName); + } + boolean newSingleton = false; - boolean recordSuppressedExceptions = (this.suppressedExceptions == null); + boolean recordSuppressedExceptions = (locked && this.suppressedExceptions == null); if (recordSuppressedExceptions) { this.suppressedExceptions = new LinkedHashSet<>(); } @@ -262,11 +374,41 @@ public Object getSingleton(String beanName, ObjectFactory singletonFactory) { } return singletonObject; } + finally { + if (locked) { + this.singletonLock.unlock(); + } + if (lenient) { + this.lenientCreationLock.lock(); + try { + this.singletonsInLenientCreation.remove(beanName); + this.lenientCreationFinished.signalAll(); + } + finally { + this.lenientCreationLock.unlock(); + } + } + } + } + + /** + * Determine whether the current thread is allowed to hold the singleton lock. + *

    By default, any thread may acquire and hold the singleton lock, except + * background threads from {@link DefaultListableBeanFactory#setBootstrapExecutor}. + * @return {@code false} if the current thread is explicitly not allowed to hold + * the lock, {@code true} if it is explicitly allowed to hold the lock but also + * accepts lenient fallback behavior, or {@code null} if there is no specific + * indication (traditional behavior: always holding a full lock) + * @since 6.2 + */ + @Nullable + protected Boolean isCurrentThreadAllowedToHoldSingletonLock() { + return null; } /** * Register an exception that happened to get suppressed during the creation of a - * singleton bean instance, e.g. a temporary circular reference resolution problem. + * singleton bean instance, for example, a temporary circular reference resolution problem. *

    The default implementation preserves any given exception in this registry's * collection of suppressed exceptions, up to a limit of 100 exceptions, adding * them as related causes to an eventual top-level {@link BeanCreationException}. @@ -274,26 +416,21 @@ public Object getSingleton(String beanName, ObjectFactory singletonFactory) { * @see BeanCreationException#getRelatedCauses() */ protected void onSuppressedException(Exception ex) { - synchronized (this.singletonObjects) { - if (this.suppressedExceptions != null && this.suppressedExceptions.size() < SUPPRESSED_EXCEPTIONS_LIMIT) { - this.suppressedExceptions.add(ex); - } + if (this.suppressedExceptions != null && this.suppressedExceptions.size() < SUPPRESSED_EXCEPTIONS_LIMIT) { + this.suppressedExceptions.add(ex); } } /** - * Remove the bean with the given name from the singleton cache of this factory, - * to be able to clean up eager registration of a singleton if creation failed. + * Remove the bean with the given name from the singleton registry, either on + * regular destruction or on cleanup after early exposure when creation failed. * @param beanName the name of the bean - * @see #getSingletonMutex() */ protected void removeSingleton(String beanName) { - synchronized (this.singletonObjects) { - this.singletonObjects.remove(beanName); - this.singletonFactories.remove(beanName); - this.earlySingletonObjects.remove(beanName); - this.registeredSingletons.remove(beanName); - } + this.singletonObjects.remove(beanName); + this.singletonFactories.remove(beanName); + this.earlySingletonObjects.remove(beanName); + this.registeredSingletons.remove(beanName); } @Override @@ -303,16 +440,12 @@ public boolean containsSingleton(String beanName) { @Override public String[] getSingletonNames() { - synchronized (this.singletonObjects) { - return StringUtils.toStringArray(this.registeredSingletons); - } + return StringUtils.toStringArray(this.registeredSingletons); } @Override public int getSingletonCount() { - synchronized (this.singletonObjects) { - return this.registeredSingletons.size(); - } + return this.registeredSingletons.size(); } @@ -386,7 +519,7 @@ public void registerDisposableBean(String beanName, DisposableBean bean) { /** * Register a containment relationship between two beans, - * e.g. between an inner bean and its containing outer bean. + * for example, between an inner bean and its containing outer bean. *

    Also registers the containing bean as dependent on the contained bean * in terms of destruction order. * @param containedBeanName the name of the contained (inner) bean @@ -508,9 +641,7 @@ public void destroySingletons() { if (logger.isTraceEnabled()) { logger.trace("Destroying singletons in " + this); } - synchronized (this.singletonObjects) { - this.singletonsCurrentlyInDestruction = true; - } + this.singletonsCurrentlyInDestruction = true; String[] disposableBeanNames; synchronized (this.disposableBeans) { @@ -524,7 +655,13 @@ public void destroySingletons() { this.dependentBeanMap.clear(); this.dependenciesForBeanMap.clear(); - clearSingletonCache(); + this.singletonLock.lock(); + try { + clearSingletonCache(); + } + finally { + this.singletonLock.unlock(); + } } /** @@ -532,13 +669,11 @@ public void destroySingletons() { * @since 4.3.15 */ protected void clearSingletonCache() { - synchronized (this.singletonObjects) { - this.singletonObjects.clear(); - this.singletonFactories.clear(); - this.earlySingletonObjects.clear(); - this.registeredSingletons.clear(); - this.singletonsCurrentlyInDestruction = false; - } + this.singletonObjects.clear(); + this.singletonFactories.clear(); + this.earlySingletonObjects.clear(); + this.registeredSingletons.clear(); + this.singletonsCurrentlyInDestruction = false; } /** @@ -548,15 +683,28 @@ protected void clearSingletonCache() { * @see #destroyBean */ public void destroySingleton(String beanName) { - // Remove a registered singleton of the given name, if any. - removeSingleton(beanName); - // Destroy the corresponding DisposableBean instance. + // This also triggers the destruction of dependent beans. DisposableBean disposableBean; synchronized (this.disposableBeans) { disposableBean = this.disposableBeans.remove(beanName); } destroyBean(beanName, disposableBean); + + // destroySingletons() removes all singleton instances at the end, + // leniently tolerating late retrieval during the shutdown phase. + if (!this.singletonsCurrentlyInDestruction) { + // For an individual destruction, remove the registered instance now. + // As of 6.2, this happens after the current bean's destruction step, + // allowing for late bean retrieval by on-demand suppliers etc. + this.singletonLock.lock(); + try { + removeSingleton(beanName); + } + finally { + this.singletonLock.unlock(); + } + } } /** @@ -621,16 +769,10 @@ protected void destroyBean(String beanName, @Nullable DisposableBean bean) { this.dependenciesForBeanMap.remove(beanName); } - /** - * Exposes the singleton mutex to subclasses and external collaborators. - *

    Subclasses should synchronize on the given Object if they perform - * any sort of extended singleton creation phase. In particular, subclasses - * should not have their own mutexes involved in singleton creation, - * to avoid the potential for deadlocks in lazy-init situations. - */ + @Deprecated(since = "6.2") @Override public final Object getSingletonMutex() { - return this.singletonObjects; + return new Object(); } } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/DisposableBeanAdapter.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/DisposableBeanAdapter.java index 14198c4b2f1f..f95fd951e3f2 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/DisposableBeanAdapter.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/DisposableBeanAdapter.java @@ -147,7 +147,7 @@ else if (paramTypes.length == 1 && boolean.class != paramTypes[0]) { beanName + "' has a non-boolean parameter - not supported as destroy method"); } } - destroyMethod = ClassUtils.getInterfaceMethodIfPossible(destroyMethod, bean.getClass()); + destroyMethod = ClassUtils.getPubliclyAccessibleMethodIfPossible(destroyMethod, bean.getClass()); destroyMethods.add(destroyMethod); } } @@ -253,8 +253,8 @@ else if (this.destroyMethodNames != null) { for (String destroyMethodName : this.destroyMethodNames) { Method destroyMethod = determineDestroyMethod(destroyMethodName); if (destroyMethod != null) { - invokeCustomDestroyMethod( - ClassUtils.getInterfaceMethodIfPossible(destroyMethod, this.bean.getClass())); + destroyMethod = ClassUtils.getPubliclyAccessibleMethodIfPossible(destroyMethod, this.bean.getClass()); + invokeCustomDestroyMethod(destroyMethod); } } } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/FactoryBeanRegistrySupport.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/FactoryBeanRegistrySupport.java index bd19a2f4fc41..ffcd87bbfbc1 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/FactoryBeanRegistrySupport.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/FactoryBeanRegistrySupport.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,12 +118,13 @@ protected Object getCachedObjectForFactoryBean(String beanName) { */ protected Object getObjectFromFactoryBean(FactoryBean factory, String beanName, boolean shouldPostProcess) { if (factory.isSingleton() && containsSingleton(beanName)) { - synchronized (getSingletonMutex()) { + this.singletonLock.lock(); + try { Object object = this.factoryBeanObjectCache.get(beanName); if (object == null) { object = doGetObjectFromFactoryBean(factory, beanName); // Only post-process and store if not put there already during getObject() call above - // (e.g. because of circular reference processing triggered by custom getBean calls) + // (for example, because of circular reference processing triggered by custom getBean calls) Object alreadyThere = this.factoryBeanObjectCache.get(beanName); if (alreadyThere != null) { object = alreadyThere; @@ -131,7 +132,7 @@ protected Object getObjectFromFactoryBean(FactoryBean factory, String beanNam else { if (shouldPostProcess) { if (isSingletonCurrentlyInCreation(beanName)) { - // Temporarily return non-post-processed object, not storing it yet.. + // Temporarily return non-post-processed object, not storing it yet return object; } beforeSingletonCreation(beanName); @@ -153,6 +154,9 @@ protected Object getObjectFromFactoryBean(FactoryBean factory, String beanNam } return object; } + finally { + this.singletonLock.unlock(); + } } else { Object object = doGetObjectFromFactoryBean(factory, beanName); @@ -234,10 +238,8 @@ protected FactoryBean getFactoryBean(String beanName, Object beanInstance) th */ @Override protected void removeSingleton(String beanName) { - synchronized (getSingletonMutex()) { - super.removeSingleton(beanName); - this.factoryBeanObjectCache.remove(beanName); - } + super.removeSingleton(beanName); + this.factoryBeanObjectCache.remove(beanName); } /** @@ -245,10 +247,8 @@ protected void removeSingleton(String beanName) { */ @Override protected void clearSingletonCache() { - synchronized (getSingletonMutex()) { - super.clearSingletonCache(); - this.factoryBeanObjectCache.clear(); - } + super.clearSingletonCache(); + this.factoryBeanObjectCache.clear(); } } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/GenericBeanDefinition.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/GenericBeanDefinition.java index 8381b7b20f24..570957cf99a5 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/GenericBeanDefinition.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/GenericBeanDefinition.java @@ -27,7 +27,7 @@ * parent bean definition can be flexibly configured through the "parentName" property. * *

    In general, use this {@code GenericBeanDefinition} class for the purpose of - * registering declarative bean definitions (e.g. XML definitions which a bean + * registering declarative bean definitions (for example, XML definitions which a bean * post-processor might operate on, potentially even reconfiguring the parent name). * Use {@code RootBeanDefinition}/{@code ChildBeanDefinition} where parent/child * relationships happen to be pre-determined, and prefer {@link RootBeanDefinition} diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/GenericTypeAwareAutowireCandidateResolver.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/GenericTypeAwareAutowireCandidateResolver.java index 7d367cfd71aa..f422594f701e 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/GenericTypeAwareAutowireCandidateResolver.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/GenericTypeAwareAutowireCandidateResolver.java @@ -33,7 +33,7 @@ /** * Basic {@link AutowireCandidateResolver} that performs a full generic type * match with the candidate's type if the dependency is declared as a generic type - * (e.g. {@code Repository}). + * (for example, {@code Repository}). * *

    This is the base class for * {@link org.springframework.beans.factory.annotation.QualifierAnnotationAutowireCandidateResolver}, @@ -73,6 +73,7 @@ public boolean isAutowireCandidate(BeanDefinitionHolder bdHolder, DependencyDesc * Match the given dependency type with its generic type information against the given * candidate bean definition. */ + @SuppressWarnings("NullAway") protected boolean checkGenericTypeMatch(BeanDefinitionHolder bdHolder, DependencyDescriptor descriptor) { ResolvableType dependencyType = descriptor.getResolvableType(); if (dependencyType.getType() instanceof Class) { @@ -145,12 +146,16 @@ protected boolean checkGenericTypeMatch(BeanDefinitionHolder bdHolder, Dependenc } } - if (descriptor.fallbackMatchAllowed() && - (targetType.hasUnresolvableGenerics() || targetType.resolve() == Properties.class)) { - // Fallback matches allow unresolvable generics, e.g. plain HashMap to Map; + if (descriptor.fallbackMatchAllowed()) { + // Fallback matches allow unresolvable generics, for example, plain HashMap to Map; // and pragmatically also java.util.Properties to any Map (since despite formally being a // Map, java.util.Properties is usually perceived as a Map). - return true; + if (targetType.hasUnresolvableGenerics()) { + return dependencyType.isAssignableFromResolvedPart(targetType); + } + else if (targetType.resolve() == Properties.class) { + return true; + } } // Full check for complex generic type match... return dependencyType.isAssignableFrom(targetType); diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/LookupOverride.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/LookupOverride.java index f51af88735c6..9cbcbebe94e9 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/LookupOverride.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/LookupOverride.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +49,7 @@ public class LookupOverride extends MethodOverride { /** - * Construct a new LookupOverride. + * Construct a new {@code LookupOverride}. * @param methodName the name of the method to override * @param beanName the name of the bean in the current {@code BeanFactory} that the * overridden method should return (may be {@code null} for type-based bean retrieval) @@ -60,7 +60,7 @@ public LookupOverride(String methodName, @Nullable String beanName) { } /** - * Construct a new LookupOverride. + * Construct a new {@code LookupOverride}. * @param method the method declaration to override * @param beanName the name of the bean in the current {@code BeanFactory} that the * overridden method should return (may be {@code null} for type-based bean retrieval) @@ -73,7 +73,7 @@ public LookupOverride(Method method, @Nullable String beanName) { /** - * Return the name of the bean that should be returned by this method. + * Return the name of the bean that should be returned by this {@code LookupOverride}. */ @Nullable public String getBeanName() { diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/MethodOverride.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/MethodOverride.java index d250320dded4..49ca03408e5e 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/MethodOverride.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/MethodOverride.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.beans.factory.support; import java.lang.reflect.Method; +import java.util.Objects; import org.springframework.beans.BeanMetadataElement; import org.springframework.lang.Nullable; @@ -107,13 +108,13 @@ public Object getSource() { @Override public boolean equals(@Nullable Object other) { return (this == other || (other instanceof MethodOverride that && - ObjectUtils.nullSafeEquals(this.methodName, that.methodName) && + this.methodName.equals(that.methodName) && ObjectUtils.nullSafeEquals(this.source, that.source))); } @Override public int hashCode() { - return ObjectUtils.nullSafeHash(this.methodName, this.source); + return Objects.hash(this.methodName, this.source); } } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/NullBean.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/NullBean.java index 7905acc09554..ab847865b5e0 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/NullBean.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/NullBean.java @@ -20,7 +20,7 @@ import org.springframework.lang.Nullable; /** - * Internal representation of a null bean instance, e.g. for a {@code null} value + * Internal representation of a null bean instance, for example, for a {@code null} value * returned from {@link FactoryBean#getObject()} or from a factory method. * *

    Each such null bean is represented by a dedicated {@code NullBean} instance diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/PropertiesBeanDefinitionReader.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/PropertiesBeanDefinitionReader.java index ebd2b6a1aa6f..37f90946393a 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/PropertiesBeanDefinitionReader.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/PropertiesBeanDefinitionReader.java @@ -128,7 +128,7 @@ public class PropertiesBeanDefinitionReader extends AbstractBeanDefinitionReader /** * Property suffix for references to other beans in the current - * BeanFactory: e.g. {@code owner.dog(ref)=fido}. + * BeanFactory: for example, {@code owner.dog(ref)=fido}. * Whether this is a reference to a singleton or a prototype * will depend on the definition of the target bean. */ @@ -165,7 +165,7 @@ public PropertiesBeanDefinitionReader(BeanDefinitionRegistry registry) { * Set the default parent bean for this bean factory. * If a child bean definition handled by this factory provides neither * a parent nor a class attribute, this default value gets used. - *

    Can be used e.g. for view definition files, to define a parent + *

    Can be used, for example, for view definition files, to define a parent * with a default view class and common attributes for all views. * View definitions that define their own parent or carry their own * class can still override this. @@ -219,7 +219,7 @@ public int loadBeanDefinitions(Resource resource) throws BeanDefinitionStoreExce /** * Load bean definitions from the specified properties file. * @param resource the resource descriptor for the properties file - * @param prefix a filter within the keys in the map: e.g. 'beans.' + * @param prefix a filter within the keys in the map: for example, 'beans.' * (can be empty or {@code null}) * @return the number of bean definitions found * @throws BeanDefinitionStoreException in case of loading or parsing errors @@ -243,7 +243,7 @@ public int loadBeanDefinitions(EncodedResource encodedResource) throws BeanDefin * Load bean definitions from the specified properties file. * @param encodedResource the resource descriptor for the properties file, * allowing to specify an encoding to use for parsing the file - * @param prefix a filter within the keys in the map: e.g. 'beans.' + * @param prefix a filter within the keys in the map: for example, 'beans.' * (can be empty or {@code null}) * @return the number of bean definitions found * @throws BeanDefinitionStoreException in case of loading or parsing errors @@ -294,7 +294,7 @@ public int registerBeanDefinitions(ResourceBundle rb) throws BeanDefinitionStore *

    Similar syntax as for a Map. This method is useful to enable * standard Java internationalization support. * @param rb the ResourceBundle to load from - * @param prefix a filter within the keys in the map: e.g. 'beans.' + * @param prefix a filter within the keys in the map: for example, 'beans.' * (can be empty or {@code null}) * @return the number of bean definitions found * @throws BeanDefinitionStoreException in case of loading or parsing errors @@ -331,7 +331,7 @@ public int registerBeanDefinitions(Map map) throws BeansException { * @param map a map of {@code name} to {@code property} (String or Object). Property * values will be strings if coming from a Properties file etc. Property names * (keys) must be Strings. Class keys must be Strings. - * @param prefix a filter within the keys in the map: e.g. 'beans.' + * @param prefix a filter within the keys in the map: for example, 'beans.' * (can be empty or {@code null}) * @return the number of bean definitions found * @throws BeansException in case of loading or parsing errors @@ -346,7 +346,7 @@ public int registerBeanDefinitions(Map map, @Nullable String prefix) throw * @param map a map of {@code name} to {@code property} (String or Object). Property * values will be strings if coming from a Properties file etc. Property names * (keys) must be Strings. Class keys must be Strings. - * @param prefix a filter within the keys in the map: e.g. 'beans.' + * @param prefix a filter within the keys in the map: for example, 'beans.' * (can be empty or {@code null}) * @param resourceDescription description of the resource that the * Map came from (for logging purposes) diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/RegisteredBean.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/RegisteredBean.java index f59b5cb2643f..3f3b25cc5ab1 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/RegisteredBean.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/RegisteredBean.java @@ -266,7 +266,7 @@ public String toString() { * Descriptor for how a bean should be instantiated. While the {@code targetClass} * is usually the declaring class of the {@code executable} (in case of a constructor * or a locally declared factory method), there are cases where retaining the actual - * concrete class is necessary (e.g. for an inherited factory method). + * concrete class is necessary (for example, for an inherited factory method). * @since 6.1.7 * @param executable the {@link Executable} ({@link java.lang.reflect.Constructor} * or {@link java.lang.reflect.Method}) to invoke diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/ReplaceOverride.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/ReplaceOverride.java index 497c60b08098..4fe5ad846236 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/ReplaceOverride.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/ReplaceOverride.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,10 +19,10 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import org.springframework.lang.Nullable; import org.springframework.util.Assert; -import org.springframework.util.ObjectUtils; /** * Extension of {@link MethodOverride} that represents an arbitrary @@ -97,22 +97,19 @@ public boolean matches(Method method) { @Override public boolean equals(@Nullable Object other) { - return (other instanceof ReplaceOverride that && super.equals(other) && - ObjectUtils.nullSafeEquals(this.methodReplacerBeanName, that.methodReplacerBeanName) && - ObjectUtils.nullSafeEquals(this.typeIdentifiers, that.typeIdentifiers)); + return (other instanceof ReplaceOverride that && super.equals(that) && + this.methodReplacerBeanName.equals(that.methodReplacerBeanName) && + this.typeIdentifiers.equals(that.typeIdentifiers)); } @Override public int hashCode() { - int hashCode = super.hashCode(); - hashCode = 29 * hashCode + ObjectUtils.nullSafeHashCode(this.methodReplacerBeanName); - hashCode = 29 * hashCode + ObjectUtils.nullSafeHashCode(this.typeIdentifiers); - return hashCode; + return Objects.hash(this.methodReplacerBeanName, this.typeIdentifiers); } @Override public String toString() { - return "Replace override for method '" + getMethodName() + "'"; + return "ReplaceOverride for method '" + getMethodName() + "'"; } } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/RootBeanDefinition.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/RootBeanDefinition.java index feae33613fb6..67aad00a312e 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/RootBeanDefinition.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/RootBeanDefinition.java @@ -37,18 +37,18 @@ /** * A root bean definition represents the merged bean definition at runtime * that backs a specific bean in a Spring BeanFactory. It might have been created - * from multiple original bean definitions that inherit from each other, e.g. + * from multiple original bean definitions that inherit from each other, for example, * {@link GenericBeanDefinition GenericBeanDefinitions} from XML declarations. * A root bean definition is essentially the 'unified' bean definition view at runtime. * *

    Root bean definitions may also be used for registering individual bean * definitions in the configuration phase. This is particularly applicable for - * programmatic definitions derived from factory methods (e.g. {@code @Bean} methods) - * and instance suppliers (e.g. lambda expressions) which come with extra type metadata + * programmatic definitions derived from factory methods (for example, {@code @Bean} methods) + * and instance suppliers (for example, lambda expressions) which come with extra type metadata * (see {@link #setTargetType(ResolvableType)}/{@link #setResolvedFactoryMethod(Method)}). * *

    Note: The preferred choice for bean definitions derived from declarative sources - * (e.g. XML definitions) is the flexible {@link GenericBeanDefinition} variant. + * (for example, XML definitions) is the flexible {@link GenericBeanDefinition} variant. * GenericBeanDefinition comes with the advantage that it allows for dynamically * defining parent dependencies, not 'hard-coding' the role as a root bean definition, * even supporting parent relationship changes in the bean post-processor phase. @@ -375,7 +375,7 @@ public ResolvableType getResolvableType() { if (returnType != null) { return returnType; } - Method factoryMethod = this.factoryMethodToIntrospect; + Method factoryMethod = getResolvedFactoryMethod(); if (factoryMethod != null) { return ResolvableType.forMethodReturnType(factoryMethod); } @@ -402,8 +402,8 @@ public Constructor[] getPreferredConstructors() { if (attribute instanceof Constructor constructor) { return new Constructor[] {constructor}; } - if (attribute instanceof Constructor[]) { - return (Constructor[]) attribute; + if (attribute instanceof Constructor[] constructors) { + return constructors; } throw new IllegalArgumentException("Invalid value type for attribute '" + PREFERRED_CONSTRUCTORS_ATTRIBUTE + "': " + attribute.getClass().getName()); @@ -453,17 +453,12 @@ public void setResolvedFactoryMethod(@Nullable Method method) { */ @Nullable public Method getResolvedFactoryMethod() { - return this.factoryMethodToIntrospect; - } - - @Override - public void setInstanceSupplier(@Nullable Supplier supplier) { - super.setInstanceSupplier(supplier); - Method factoryMethod = (supplier instanceof InstanceSupplier instanceSupplier ? - instanceSupplier.getFactoryMethod() : null); - if (factoryMethod != null) { - setResolvedFactoryMethod(factoryMethod); + Method factoryMethod = this.factoryMethodToIntrospect; + if (factoryMethod == null && + getInstanceSupplier() instanceof InstanceSupplier instanceSupplier) { + factoryMethod = instanceSupplier.getFactoryMethod(); } + return factoryMethod; } /** diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/ScopeNotActiveException.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/ScopeNotActiveException.java index bb7cddaf2a21..32049b11d3d7 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/ScopeNotActiveException.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/ScopeNotActiveException.java @@ -20,7 +20,7 @@ /** * A subclass of {@link BeanCreationException} which indicates that the target scope - * is not active, e.g. in case of request or session scope. + * is not active, for example, in case of request or session scope. * * @author Juergen Hoeller * @since 5.3 diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/SimpleAutowireCandidateResolver.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/SimpleAutowireCandidateResolver.java index 2afdf73924a4..b67fdb6e548f 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/SimpleAutowireCandidateResolver.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/SimpleAutowireCandidateResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 the original author 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,9 +16,13 @@ package org.springframework.beans.factory.support; -import org.springframework.beans.factory.config.BeanDefinitionHolder; -import org.springframework.beans.factory.config.DependencyDescriptor; -import org.springframework.lang.Nullable; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; /** * {@link AutowireCandidateResolver} implementation to use when no annotation @@ -36,40 +40,6 @@ public class SimpleAutowireCandidateResolver implements AutowireCandidateResolve */ public static final SimpleAutowireCandidateResolver INSTANCE = new SimpleAutowireCandidateResolver(); - - @Override - public boolean isAutowireCandidate(BeanDefinitionHolder bdHolder, DependencyDescriptor descriptor) { - return bdHolder.getBeanDefinition().isAutowireCandidate(); - } - - @Override - public boolean isRequired(DependencyDescriptor descriptor) { - return descriptor.isRequired(); - } - - @Override - public boolean hasQualifier(DependencyDescriptor descriptor) { - return false; - } - - @Override - @Nullable - public Object getSuggestedValue(DependencyDescriptor descriptor) { - return null; - } - - @Override - @Nullable - public Object getLazyResolutionProxyIfNecessary(DependencyDescriptor descriptor, @Nullable String beanName) { - return null; - } - - @Override - @Nullable - public Class getLazyResolutionProxyClass(DependencyDescriptor descriptor, @Nullable String beanName) { - return null; - } - /** * This implementation returns {@code this} as-is. * @see #INSTANCE @@ -79,4 +49,31 @@ public AutowireCandidateResolver cloneIfNecessary() { return this; } + + /** + * Resolve a map of all beans of the given type, also picking up beans defined in + * ancestor bean factories, with the specific condition that each bean actually + * has autowire candidate status. This matches simple injection point resolution + * as implemented by this {@link AutowireCandidateResolver} strategy, including + * beans which are not marked as default candidates but excluding beans which + * are not even marked as autowire candidates. + * @param lbf the bean factory + * @param type the type of bean to match + * @return the Map of matching bean instances, or an empty Map if none + * @throws BeansException if a bean could not be created + * @since 6.2.3 + * @see BeanFactoryUtils#beansOfTypeIncludingAncestors(ListableBeanFactory, Class) + * @see org.springframework.beans.factory.config.BeanDefinition#isAutowireCandidate() + * @see AbstractBeanDefinition#isDefaultCandidate() + */ + public static Map resolveAutowireCandidates(ConfigurableListableBeanFactory lbf, Class type) { + Map candidates = new LinkedHashMap<>(); + for (String beanName : BeanFactoryUtils.beanNamesForTypeIncludingAncestors(lbf, type)) { + if (AutowireUtils.isAutowireCandidate(lbf, beanName)) { + candidates.put(beanName, lbf.getBean(beanName, type)); + } + } + return candidates; + } + } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/SimpleInstantiationStrategy.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/SimpleInstantiationStrategy.java index 49c38d7e7389..aec5fadd4ab8 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/SimpleInstantiationStrategy.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/SimpleInstantiationStrategy.java @@ -19,6 +19,7 @@ import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.util.function.Supplier; import org.springframework.beans.BeanInstantiationException; import org.springframework.beans.BeanUtils; @@ -59,7 +60,9 @@ public static Method getCurrentlyInvokedFactoryMethod() { * the current value, if any. * @param method the factory method currently being invoked or {@code null} * @since 6.0 + * @deprecated in favor of {@link #instantiateWithFactoryMethod(Method, Supplier)} */ + @Deprecated(since = "6.2", forRemoval = true) public static void setCurrentlyInvokedFactoryMethod(@Nullable Method method) { if (method != null) { currentlyInvokedFactoryMethod.set(method); @@ -69,6 +72,31 @@ public static void setCurrentlyInvokedFactoryMethod(@Nullable Method method) { } } + /** + * Invoke the given {@code instanceSupplier} with the factory method exposed + * as being invoked. + * @param method the factory method to expose + * @param instanceSupplier the instance supplier + * @param the type of the instance + * @return the result of the instance supplier + * @since 6.2 + */ + public static T instantiateWithFactoryMethod(Method method, Supplier instanceSupplier) { + Method priorInvokedFactoryMethod = currentlyInvokedFactoryMethod.get(); + try { + currentlyInvokedFactoryMethod.set(method); + return instanceSupplier.get(); + } + finally { + if (priorInvokedFactoryMethod != null) { + currentlyInvokedFactoryMethod.set(priorInvokedFactoryMethod); + } + else { + currentlyInvokedFactoryMethod.remove(); + } + } + } + @Override public Object instantiate(RootBeanDefinition bd, @Nullable String beanName, BeanFactory owner) { @@ -137,46 +165,40 @@ protected Object instantiateWithMethodInjection(RootBeanDefinition bd, @Nullable public Object instantiate(RootBeanDefinition bd, @Nullable String beanName, BeanFactory owner, @Nullable Object factoryBean, Method factoryMethod, Object... args) { - try { - ReflectionUtils.makeAccessible(factoryMethod); - - Method priorInvokedFactoryMethod = getCurrentlyInvokedFactoryMethod(); + return instantiateWithFactoryMethod(factoryMethod, () -> { try { - setCurrentlyInvokedFactoryMethod(factoryMethod); + ReflectionUtils.makeAccessible(factoryMethod); Object result = factoryMethod.invoke(factoryBean, args); if (result == null) { result = new NullBean(); } return result; } - finally { - setCurrentlyInvokedFactoryMethod(priorInvokedFactoryMethod); + catch (IllegalArgumentException ex) { + if (factoryBean != null && !factoryMethod.getDeclaringClass().isAssignableFrom(factoryBean.getClass())) { + throw new BeanInstantiationException(factoryMethod, + "Illegal factory instance for factory method '" + factoryMethod.getName() + "'; " + + "instance: " + factoryBean.getClass().getName(), ex); + } + throw new BeanInstantiationException(factoryMethod, + "Illegal arguments to factory method '" + factoryMethod.getName() + "'; " + + "args: " + StringUtils.arrayToCommaDelimitedString(args), ex); } - } - catch (IllegalArgumentException ex) { - if (factoryBean != null && !factoryMethod.getDeclaringClass().isAssignableFrom(factoryBean.getClass())) { + catch (IllegalAccessException ex) { throw new BeanInstantiationException(factoryMethod, - "Illegal factory instance for factory method '" + factoryMethod.getName() + "'; " + - "instance: " + factoryBean.getClass().getName(), ex); + "Cannot access factory method '" + factoryMethod.getName() + "'; is it public?", ex); } - throw new BeanInstantiationException(factoryMethod, - "Illegal arguments to factory method '" + factoryMethod.getName() + "'; " + - "args: " + StringUtils.arrayToCommaDelimitedString(args), ex); - } - catch (IllegalAccessException ex) { - throw new BeanInstantiationException(factoryMethod, - "Cannot access factory method '" + factoryMethod.getName() + "'; is it public?", ex); - } - catch (InvocationTargetException ex) { - String msg = "Factory method '" + factoryMethod.getName() + "' threw exception with message: " + - ex.getTargetException().getMessage(); - if (bd.getFactoryBeanName() != null && owner instanceof ConfigurableBeanFactory cbf && - cbf.isCurrentlyInCreation(bd.getFactoryBeanName())) { - msg = "Circular reference involving containing bean '" + bd.getFactoryBeanName() + "' - consider " + - "declaring the factory method as static for independence from its containing instance. " + msg; + catch (InvocationTargetException ex) { + String msg = "Factory method '" + factoryMethod.getName() + "' threw exception with message: " + + ex.getTargetException().getMessage(); + if (bd.getFactoryBeanName() != null && owner instanceof ConfigurableBeanFactory cbf && + cbf.isCurrentlyInCreation(bd.getFactoryBeanName())) { + msg = "Circular reference involving containing bean '" + bd.getFactoryBeanName() + "' - consider " + + "declaring the factory method as static for independence from its containing instance. " + msg; + } + throw new BeanInstantiationException(factoryMethod, msg, ex.getTargetException()); } - throw new BeanInstantiationException(factoryMethod, msg, ex.getTargetException()); - } + }); } } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/StaticListableBeanFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/StaticListableBeanFactory.java index e93b7da2e359..c9bfc61de581 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/StaticListableBeanFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/StaticListableBeanFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +37,6 @@ import org.springframework.beans.factory.NoUniqueBeanDefinitionException; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.SmartFactoryBean; -import org.springframework.core.OrderComparator; import org.springframework.core.ResolvableType; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.lang.Nullable; @@ -344,10 +343,6 @@ public T getIfUnique() throws BeansException { public Stream stream() { return Arrays.stream(getBeanNamesForType(requiredType)).map(name -> (T) getBean(name)); } - @Override - public Stream orderedStream() { - return stream().sorted(OrderComparator.INSTANCE); - } }; } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/xml/BeanDefinitionParserDelegate.java b/spring-beans/src/main/java/org/springframework/beans/factory/xml/BeanDefinitionParserDelegate.java index a44eb84416ec..84bf8629d5f4 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/xml/BeanDefinitionParserDelegate.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/xml/BeanDefinitionParserDelegate.java @@ -411,6 +411,7 @@ public BeanDefinitionHolder parseBeanDefinitionElement(Element ele) { * {@link org.springframework.beans.factory.parsing.ProblemReporter}. */ @Nullable + @SuppressWarnings("NullAway") public BeanDefinitionHolder parseBeanDefinitionElement(Element ele, @Nullable BeanDefinition containingBean) { String id = ele.getAttribute(ID_ATTRIBUTE); String nameAttr = ele.getAttribute(NAME_ATTRIBUTE); @@ -889,7 +890,7 @@ public void parseQualifierElement(Element ele, AbstractBeanDefinition bd) { qualifier.addMetadataAttribute(attribute); } else { - error("Qualifier 'attribute' tag must have a 'name' and 'value'", attributeEle); + error("Qualifier 'attribute' tag must have a 'key' and 'value'", attributeEle); return; } } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/xml/DefaultBeanDefinitionDocumentReader.java b/spring-beans/src/main/java/org/springframework/beans/factory/xml/DefaultBeanDefinitionDocumentReader.java index 0e556b94bc8f..b75e54893a74 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/xml/DefaultBeanDefinitionDocumentReader.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/xml/DefaultBeanDefinitionDocumentReader.java @@ -213,7 +213,7 @@ protected void importBeanDefinitionResource(Element ele) { return; } - // Resolve system properties: e.g. "${user.dir}" + // Resolve system properties: for example, "${user.dir}" location = getReaderContext().getEnvironment().resolveRequiredPlaceholders(location); Set actualResources = new LinkedHashSet<>(4); diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/xml/DefaultDocumentLoader.java b/spring-beans/src/main/java/org/springframework/beans/factory/xml/DefaultDocumentLoader.java index 08b1d16f2778..77ec6469f13e 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/xml/DefaultDocumentLoader.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/xml/DefaultDocumentLoader.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2024 the original author 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,9 @@ public Document loadDocument(InputSource inputSource, EntityResolver entityResol protected DocumentBuilderFactory createDocumentBuilderFactory(int validationMode, boolean namespaceAware) throws ParserConfigurationException { + // This document loader is used for loading application configuration files. + // As a result, attackers would need complete write access to application configuration + // to leverage XXE attacks. This does not qualify as privilege escalation. DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(namespaceAware); diff --git a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/CharacterEditor.java b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/CharacterEditor.java index ec7c7d4c9b2c..727be75c8694 100644 --- a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/CharacterEditor.java +++ b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/CharacterEditor.java @@ -30,7 +30,7 @@ * {@link org.springframework.beans.BeanWrapperImpl} will register this * editor by default. * - *

    Also supports conversion from a Unicode character sequence; e.g. + *

    Also supports conversion from a Unicode character sequence; for example, * {@code u0041} ('A'). * * @author Juergen Hoeller diff --git a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/CharsetEditor.java b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/CharsetEditor.java index ef772db749cb..5fb86ae962ad 100644 --- a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/CharsetEditor.java +++ b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/CharsetEditor.java @@ -26,7 +26,7 @@ * String representations into Charset objects and back. * *

    Expects the same syntax as Charset's {@link java.nio.charset.Charset#name()}, - * e.g. {@code UTF-8}, {@code ISO-8859-16}, etc. + * for example, {@code UTF-8}, {@code ISO-8859-16}, etc. * * @author Arjen Poutsma * @author Sam Brannen diff --git a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/InputStreamEditor.java b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/InputStreamEditor.java index fca24a5418ef..303067c5e253 100644 --- a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/InputStreamEditor.java +++ b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/InputStreamEditor.java @@ -27,7 +27,7 @@ /** * One-way PropertyEditor which can convert from a text String to a * {@code java.io.InputStream}, interpreting the given String as a - * Spring resource location (e.g. a URL String). + * Spring resource location (for example, a URL String). * *

    Supports Spring-style URL notation: any fully qualified standard URL * ("file:", "http:", etc.) and Spring's special "classpath:" pseudo-URL. diff --git a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/LocaleEditor.java b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/LocaleEditor.java index c2c415deab55..85eedaac60a2 100644 --- a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/LocaleEditor.java +++ b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/LocaleEditor.java @@ -24,7 +24,7 @@ * Editor for {@code java.util.Locale}, to directly populate a Locale property. * *

    Expects the same syntax as Locale's {@code toString()}, i.e. language + - * optionally country + optionally variant, separated by "_" (e.g. "en", "en_US"). + * optionally country + optionally variant, separated by "_" (for example, "en", "en_US"). * Also accepts spaces as separators, as an alternative to underscores. * * @author Juergen Hoeller diff --git a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/ReaderEditor.java b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/ReaderEditor.java index a388932bfca0..46171fb4b774 100644 --- a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/ReaderEditor.java +++ b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/ReaderEditor.java @@ -28,7 +28,7 @@ /** * One-way PropertyEditor which can convert from a text String to a * {@code java.io.Reader}, interpreting the given String as a Spring - * resource location (e.g. a URL String). + * resource location (for example, a URL String). * *

    Supports Spring-style URL notation: any fully qualified standard URL * ("file:", "http:", etc.) and Spring's special "classpath:" pseudo-URL. diff --git a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/StringArrayPropertyEditor.java b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/StringArrayPropertyEditor.java index e278c8721b65..7149e931df03 100644 --- a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/StringArrayPropertyEditor.java +++ b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/StringArrayPropertyEditor.java @@ -97,7 +97,7 @@ public StringArrayPropertyEditor(String separator, boolean emptyArrayAsNull, boo * @param separator the separator to use for splitting a {@link String} * @param charsToDelete a set of characters to delete, in addition to * trimming an input String. Useful for deleting unwanted line breaks: - * e.g. "\r\n\f" will delete all new lines and line feeds in a String. + * for example, "\r\n\f" will delete all new lines and line feeds in a String. * @param emptyArrayAsNull {@code true} if an empty String array * is to be transformed into {@code null} */ @@ -110,7 +110,7 @@ public StringArrayPropertyEditor(String separator, @Nullable String charsToDelet * @param separator the separator to use for splitting a {@link String} * @param charsToDelete a set of characters to delete, in addition to * trimming an input String. Useful for deleting unwanted line breaks: - * e.g. "\r\n\f" will delete all new lines and line feeds in a String. + * for example, "\r\n\f" will delete all new lines and line feeds in a String. * @param emptyArrayAsNull {@code true} if an empty String array * is to be transformed into {@code null} * @param trimValues {@code true} if the values in the parsed arrays diff --git a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/StringTrimmerEditor.java b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/StringTrimmerEditor.java index 0fbbfd327ba7..d97037c56364 100644 --- a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/StringTrimmerEditor.java +++ b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/StringTrimmerEditor.java @@ -25,7 +25,7 @@ * Property editor that trims Strings. * *

    Optionally allows transforming an empty string into a {@code null} value. - * Needs to be explicitly registered, e.g. for command binding. + * Needs to be explicitly registered, for example, for command binding. * * @author Juergen Hoeller * @see org.springframework.validation.DataBinder#registerCustomEditor @@ -52,7 +52,7 @@ public StringTrimmerEditor(boolean emptyAsNull) { * Create a new StringTrimmerEditor. * @param charsToDelete a set of characters to delete, in addition to * trimming an input String. Useful for deleting unwanted line breaks: - * e.g. "\r\n\f" will delete all new lines and line feeds in a String. + * for example, "\r\n\f" will delete all new lines and line feeds in a String. * @param emptyAsNull {@code true} if an empty String is to be * transformed into {@code null} */ diff --git a/spring-beans/src/main/java/org/springframework/beans/support/ResourceEditorRegistrar.java b/spring-beans/src/main/java/org/springframework/beans/support/ResourceEditorRegistrar.java index f5c931217696..45ea32de3fcf 100644 --- a/spring-beans/src/main/java/org/springframework/beans/support/ResourceEditorRegistrar.java +++ b/spring-beans/src/main/java/org/springframework/beans/support/ResourceEditorRegistrar.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author 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,4 +135,12 @@ private void doRegisterEditor(PropertyEditorRegistry registry, Class required } } + /** + * Indicate the use of {@link PropertyEditorRegistrySupport#overrideDefaultEditor} above. + */ + @Override + public boolean overridesDefaultEditors() { + return true; + } + } diff --git a/spring-beans/src/main/resources/org/springframework/beans/factory/xml/spring-beans.dtd b/spring-beans/src/main/resources/org/springframework/beans/factory/xml/spring-beans.dtd index 42f487cfeb3f..27b7b3434714 100644 --- a/spring-beans/src/main/resources/org/springframework/beans/factory/xml/spring-beans.dtd +++ b/spring-beans/src/main/resources/org/springframework/beans/factory/xml/spring-beans.dtd @@ -137,8 +137,8 @@ @@ -343,14 +343,14 @@ @@ -451,8 +451,8 @@ represented as an int in compiled bytecode, + // which does not require unboxing since get(int) method expects an int. + // Falls in range [ICONST_0, ICONST_5] + expression = parser.parseExpression("[1]"); + assertCannotCompile(expression); + + assertThat(expression.getValue(context, colors)).isEqualTo(Color.BLUE); + assertCanCompile(expression); + assertThat(expression.getValue(context, colors)).isEqualTo(Color.BLUE); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + + // IntLiteral as index --> represented as an int in compiled bytecode, + // which does not require unboxing since get(int) method expects an int. + // Does not fall in range [ICONST_0, ICONST_5] + expression = parser.parseExpression("[42]"); + assertCannotCompile(expression); + + assertThat(expression.getValue(context, colors)).isEqualTo(Color.PURPLE); + assertCanCompile(expression); + assertThat(expression.getValue(context, colors)).isEqualTo(Color.PURPLE); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + + // Integer variable as index --> represented as an Integer in compiled bytecode, + // which requires unboxing from Integer to int since get(int) method expects an int. + context.setVariable("colorIndex", 2); + expression = parser.parseExpression("[#colorIndex]"); + assertCannotCompile(expression); + + assertThat(expression.getValue(context, colors)).isEqualTo(Color.GREEN); + assertCanCompile(expression); + assertThat(expression.getValue(context, colors)).isEqualTo(Color.GREEN); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + + // Reuse expression but change value of colorIndex. + context.setVariable("colorIndex", 3); + + assertThat(expression.getValue(context, colors)).isEqualTo(Color.ORANGE); + assertCanCompile(expression); + assertThat(expression.getValue(context, colors)).isEqualTo(Color.ORANGE); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + + // Set color at index 3. + expression.setValue(context, colors, Color.RED); + assertCanCompile(expression); + assertThat(expression.getValue(context, colors)).isEqualTo(Color.RED); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + } + + @Test + void indexWithPrimitiveIndexTypeAndReferenceValueTypeAccessedViaList() { + String exitTypeDescriptor = CodeFlow.toDescriptor(Color.class); + + StandardEvaluationContext context = new StandardEvaluationContext(); + context.addIndexAccessor(new ColorsIndexAccessor()); + context.setVariable("list", List.of(new Colors())); + + expression = parser.parseExpression("#list.get(0)[0]"); + assertCannotCompile(expression); + + assertThatExceptionOfType(SpelEvaluationException.class) + .isThrownBy(() -> expression.getValue(context)) + .withMessageEndingWith("A problem occurred while attempting to read index '%s' in '%s'", + 0, Colors.class.getName()) + .withCauseInstanceOf(IndexOutOfBoundsException.class) + .extracting(SpelEvaluationException::getMessageCode).isEqualTo(EXCEPTION_DURING_INDEX_READ); + assertCannotCompile(expression); + + // IntLiteral as index --> represented as an int in compiled bytecode, + // which does not require unboxing since get(int) method expects an int. + expression = parser.parseExpression("#list.get(0)[1]"); + assertCannotCompile(expression); + + assertThat(expression.getValue(context)).isEqualTo(Color.BLUE); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo(Color.BLUE); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + + // Integer variable as index --> represented as an Integer in compiled bytecode, + // which requires unboxing from Integer to int since get(int) method expects an int. + context.setVariable("colorIndex", 2); + expression = parser.parseExpression("#list.get(0)[#colorIndex]"); + assertCannotCompile(expression); + + assertThat(expression.getValue(context)).isEqualTo(Color.GREEN); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo(Color.GREEN); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + + // Reuse expression but change value of colorIndex. + context.setVariable("colorIndex", 3); + + assertThat(expression.getValue(context)).isEqualTo(Color.ORANGE); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo(Color.ORANGE); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + } + + @Test + void indexWithReferenceIndexTypeAndPrimitiveValueType() { + String exitTypeDescriptor = CodeFlow.toDescriptor(int.class); + + StandardEvaluationContext context = new StandardEvaluationContext(); + context.addIndexAccessor(new ColorOrdinalsIndexAccessor()); + context.setVariable("colorOrdinals", new ColorOrdinals()); + context.setVariable("color", Color.GREEN); + + expression = parser.parseExpression("#colorOrdinals[#color]"); + assertCannotCompile(expression); + + assertThat(expression.getValue(context)).isEqualTo(Color.GREEN.ordinal()); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo(Color.GREEN.ordinal()); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + + // Reuse expression but change value of color. + context.setVariable("color", Color.BLUE); + + assertThat(expression.getValue(context)).isEqualTo(Color.BLUE.ordinal()); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo(Color.BLUE.ordinal()); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + } + + @ParameterizedTest + @MethodSource("fruitMapIndexAccessors") + void indexWithReferenceIndexTypeAndReferenceValueType(IndexAccessor indexAccessor) { + String exitTypeDescriptor = CodeFlow.toDescriptor(String.class); + + StandardEvaluationContext context = new StandardEvaluationContext(); + context.addIndexAccessor(indexAccessor); + context.setVariable("list", List.of(new FruitMap())); + + expression = parser.parseExpression("#list.get(0)[T(example.Color).PURPLE]"); + assertCannotCompile(expression); + + assertThatExceptionOfType(SpelEvaluationException.class) + .isThrownBy(() -> expression.getValue(context)) + .withMessageEndingWith("A problem occurred while attempting to read index '%s' in '%s'", + Color.PURPLE, FruitMap.class.getName()) + .withCauseInstanceOf(IllegalArgumentException.class) + .extracting(SpelEvaluationException::getMessageCode).isEqualTo(EXCEPTION_DURING_INDEX_READ); + assertCannotCompile(expression); + + expression = parser.parseExpression("#list[0][T(example.Color).RED]"); + assertCannotCompile(expression); + + assertThat(expression.getValue(context)).isEqualTo("cherry"); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo("cherry"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + + context.setVariable("color", Color.GREEN); + expression = parser.parseExpression("#list[0][#color]"); + assertCannotCompile(expression); + + assertThat(expression.getValue(context)).isEqualTo("kiwi"); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo("kiwi"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + + // Reuse expression but change value of color. + context.setVariable("color", Color.BLUE); + + assertThat(expression.getValue(context)).isEqualTo("blueberry"); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo("blueberry"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + + // Set fruit for purple + context.setVariable("color", Color.PURPLE); + expression.setValue(context, "plum"); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo("plum"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + } + + static Stream fruitMapIndexAccessors() { + return Stream.of( + argumentSet("FruitMapIndexAccessor", + new FruitMapIndexAccessor()), + argumentSet("ReflectiveIndexAccessor", + new ReflectiveIndexAccessor(FruitMap.class, Color.class, "getFruit", "setFruit")) + ); + } + } + } + + @Nested + class NullSafeIndexTests { // gh-29847 + + private final RootContextWithIndexedProperties rootContext = new RootContextWithIndexedProperties(); + + private final StandardEvaluationContext context = new StandardEvaluationContext(rootContext); + + @Test + void nullSafeIndexIntoPrimitiveIntArray() { + expression = parser.parseExpression("intArray?.[0]"); + + // Cannot compile before the array type is known. + assertThat(expression.getValue(context)).isNull(); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isNull(); + + rootContext.intArray = new int[] { 8, 9, 10 }; + assertThat(expression.getValue(context)).isEqualTo(8); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo(8); + // Normally we would expect the exit type descriptor to be "I" for an + // element of an int[]. However, with null-safe indexing support the + // only way for it to evaluate to null is to box the 'int' to an 'Integer'. + assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Integer"); + + // Null-safe support should have been compiled once the array type is known. + rootContext.intArray = null; + assertThat(expression.getValue(context)).isNull(); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Integer"); + } + + @Test + void nullSafeIndexIntoNumberArray() { + expression = parser.parseExpression("numberArray?.[0]"); + + // Cannot compile before the array type is known. + assertThat(expression.getValue(context)).isNull(); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isNull(); + + rootContext.numberArray = new Number[] { 8, 9, 10 }; + assertThat(expression.getValue(context)).isEqualTo(8); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo(8); + assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Number"); + + // Null-safe support should have been compiled once the array type is known. + rootContext.numberArray = null; + assertThat(expression.getValue(context)).isNull(); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Number"); + } + + @Test + void nullSafeIndexIntoList() { + expression = parser.parseExpression("list?.[0]"); + + // Cannot compile before the list type is known. + assertThat(expression.getValue(context)).isNull(); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isNull(); + + rootContext.list = List.of(42); + assertThat(expression.getValue(context)).isEqualTo(42); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo(42); + assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Object"); + + // Null-safe support should have been compiled once the list type is known. + rootContext.list = null; + assertThat(expression.getValue(context)).isNull(); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Object"); + } + + @Test + void nullSafeIndexIntoSetCannotBeCompiled() { + expression = parser.parseExpression("set?.[0]"); + + assertThat(expression.getValue(context)).isNull(); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isNull(); + + rootContext.set = Set.of(42); + assertThat(expression.getValue(context)).isEqualTo(42); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isEqualTo(42); + assertThat(getAst().getExitDescriptor()).isNull(); + } + + @Test + void nullSafeIndexIntoStringCannotBeCompiled() { + expression = parser.parseExpression("string?.[0]"); + + assertThat(expression.getValue(context)).isNull(); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isNull(); + + rootContext.string = "XYZ"; + assertThat(expression.getValue(context)).isEqualTo("X"); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isEqualTo("X"); + assertThat(getAst().getExitDescriptor()).isNull(); + } + + @Test + void nullSafeIndexIntoMap() { + expression = parser.parseExpression("map?.['enigma']"); + + // Cannot compile before the map type is known. + assertThat(expression.getValue(context)).isNull(); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isNull(); + + rootContext.map = Map.of("enigma", 42); + assertThat(expression.getValue(context)).isEqualTo(42); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo(42); + assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Object"); + + // Null-safe support should have been compiled once the map type is known. + rootContext.map = null; + assertThat(expression.getValue(context)).isNull(); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Object"); + } + + @Test + void nullSafeIndexIntoObjectViaPrimitiveProperty() { + expression = parser.parseExpression("person?.['age']"); + + // Cannot compile before the Person type is known. + assertThat(expression.getValue(context)).isNull(); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isNull(); + + rootContext.person = new Person("Jane"); + rootContext.person.setAge(42); + assertThat(expression.getValue(context)).isEqualTo(42); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo(42); + // Normally we would expect the exit type descriptor to be "I" for + // an int. However, with null-safe indexing support the only way + // for it to evaluate to null is to box the 'int' to an 'Integer'. + assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Integer"); + + // Null-safe support should have been compiled once the Person type is known. + rootContext.person = null; + assertThat(expression.getValue(context)).isNull(); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Integer"); + } + + @Test + void nullSafeIndexIntoObjectViaStringProperty() { + expression = parser.parseExpression("person?.['name']"); + + // Cannot compile before the Person type is known. + assertThat(expression.getValue(context)).isNull(); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isNull(); + + rootContext.person = new Person("Jane"); + assertThat(expression.getValue(context)).isEqualTo("Jane"); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo("Jane"); + assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/String"); + + // Null-safe support should have been compiled once the Person type is known. + rootContext.person = null; + assertThat(expression.getValue(context)).isNull(); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/String"); + } + + @Nested + class NullSafeIndexAccessorTests { + + @Test + void nullSafeIndexWithReferenceIndexTypeAndPrimitiveValueType() { + // Integer instead of int, since null-safe operators can return null. + String exitTypeDescriptor = CodeFlow.toDescriptor(Integer.class); + + StandardEvaluationContext context = new StandardEvaluationContext(); + context.addIndexAccessor(new ColorOrdinalsIndexAccessor()); + context.setVariable("color", Color.GREEN); + + expression = parser.parseExpression("#colorOrdinals?.[#color]"); + assertCannotCompile(expression); + + // Cannot compile before the indexed value type is known. + assertThat(expression.getValue(context)).isNull(); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isNull(); + + context.setVariable("colorOrdinals", new ColorOrdinals()); + + assertThat(expression.getValue(context)).isEqualTo(Color.GREEN.ordinal()); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo(Color.GREEN.ordinal()); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + + // Null-safe support should have been compiled once the indexed value type is known. + context.setVariable("colorOrdinals", null); + assertThat(expression.getValue(context)).isNull(); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + } + + @Test + void nullSafeIndexWithReferenceIndexTypeAndReferenceValueType() { + String exitTypeDescriptor = CodeFlow.toDescriptor(String.class); + + StandardEvaluationContext context = new StandardEvaluationContext(); + context.addIndexAccessor(new FruitMapIndexAccessor()); + context.setVariable("color", Color.RED); + + expression = parser.parseExpression("#fruitMap?.[#color]"); + + // Cannot compile before the indexed value type is known. + assertThat(expression.getValue(context)).isNull(); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isNull(); + + context.setVariable("fruitMap", new FruitMap()); + + assertThat(expression.getValue(context)).isEqualTo("cherry"); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isEqualTo("cherry"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + + // Null-safe support should have been compiled once the indexed value type is known. + context.setVariable("fruitMap", null); + assertThat(expression.getValue(context)).isNull(); + assertCanCompile(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitTypeDescriptor); + } + } + } + + @Nested + class PropertyVisibilityTests { + + @Test + void privateSubclassOverridesPropertyInPublicInterface() { + expression = parser.parseExpression("text"); + PrivateSubclass privateSubclass = new PrivateSubclass(); + + // Prerequisite: type must not be public for this use case. + assertNotPublic(privateSubclass.getClass()); + + String result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("enigma"); + + assertCanCompile(expression); + result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("enigma"); + } + + @Test + void privateSubclassOverridesPropertyInPrivateInterface() { + expression = parser.parseExpression("message"); + PrivateSubclass privateSubclass = new PrivateSubclass(); + + // Prerequisite: type must not be public for this use case. + assertNotPublic(privateSubclass.getClass()); + + String result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("hello"); + + assertCanCompile(expression); + result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("hello"); + } + + @Test + void privateSubclassOverridesPropertyInPublicSuperclass() { + expression = parser.parseExpression("number"); + PrivateSubclass privateSubclass = new PrivateSubclass(); + + // Prerequisite: type must not be public for this use case. + assertNotPublic(privateSubclass.getClass()); + + Integer result = expression.getValue(context, privateSubclass, Integer.class); + assertThat(result).isEqualTo(2); + + assertCanCompile(expression); + result = expression.getValue(context, privateSubclass, Integer.class); + assertThat(result).isEqualTo(2); + } + + @Test + void indexIntoPropertyInPrivateSubclassThatOverridesPropertyInPublicInterface() { + expression = parser.parseExpression("#root['text']"); + PrivateSubclass privateSubclass = new PrivateSubclass(); + + // Prerequisite: type must not be public for this use case. + assertNotPublic(privateSubclass.getClass()); + + String result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("enigma"); + + assertCanCompile(expression); + result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("enigma"); + } + + @Test + void indexIntoPropertyInPrivateSubclassThatOverridesPropertyInPrivateInterface() { + expression = parser.parseExpression("#root['message']"); + PrivateSubclass privateSubclass = new PrivateSubclass(); + + // Prerequisite: type must not be public for this use case. + assertNotPublic(privateSubclass.getClass()); + + String result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("hello"); + + assertCanCompile(expression); + result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("hello"); + } + + @Test + void indexIntoPropertyInPrivateSubclassThatOverridesPropertyInPublicSuperclass() { + expression = parser.parseExpression("#root['number']"); + PrivateSubclass privateSubclass = new PrivateSubclass(); + + // Prerequisite: type must not be public for this use case. + assertNotPublic(privateSubclass.getClass()); + + Integer result = expression.getValue(context, privateSubclass, Integer.class); + assertThat(result).isEqualTo(2); + + assertCanCompile(expression); + result = expression.getValue(context, privateSubclass, Integer.class); + assertThat(result).isEqualTo(2); + } + } + + @Nested + class MethodVisibilityTests { + + /** + * Note that {@link InlineList} creates a list and wraps it via + * {@link Collections#unmodifiableList(List)}, whose concrete type is + * package private. + */ + @Test + void packagePrivateSubclassOverridesMethodInPublicInterface() { + expression = parser.parseExpression("{2021, 2022}"); + List inlineList = expression.getValue(List.class); + + // Prerequisite: type must not be public for this use case. + assertNotPublic(inlineList.getClass()); + + expression = parser.parseExpression("{2021, 2022}.contains(2022)"); + Boolean result = expression.getValue(context, Boolean.class); + assertThat(result).isTrue(); + + assertCanCompile(expression); + result = expression.getValue(context, Boolean.class); + assertThat(result).isTrue(); + } + + @Test + void packagePrivateSubclassOverridesMethodInPrivateInterface() { + expression = parser.parseExpression("greet('Jane')"); + LocalPrivateSubclass privateSubclass = new LocalPrivateSubclass(); + + // Prerequisite: type must not be public for this use case. + assertNotPublic(privateSubclass.getClass()); + + String result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("Hello, Jane"); + + assertCanCompile(expression); + result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("Hello, Jane"); + } + + @Test + void privateSubclassOverridesMethodInPublicSuperclass() { + expression = parser.parseExpression("process(2)"); + LocalPrivateSubclass privateSubclass = new LocalPrivateSubclass(); + + // Prerequisite: type must not be public for this use case. + assertNotPublic(privateSubclass.getClass()); + + Integer result = expression.getValue(context, privateSubclass, Integer.class); + assertThat(result).isEqualTo(2 * 2); + + assertCanCompile(expression); + result = expression.getValue(context, privateSubclass, Integer.class); + assertThat(result).isEqualTo(2 * 2); + } + + // Cannot be named PrivateInterface due to issues with the Kotlin compiler. + private interface LocalPrivateInterface { + + String greet(String name); + } + + // Cannot be named PrivateSubclass due to issues with the Kotlin compiler. + private static class LocalPrivateSubclass extends PublicSuperclass implements LocalPrivateInterface { + + @Override + public int process(int num) { + return num * 2; + } + + @Override + public String greet(String name) { + return "Hello, " + name; + } + } + } + + @Nested + class ReflectiveIndexAccessorVisibilityTests { + + @Test + void privateSubclassOverridesIndexReadMethodInPublicInterface() { + PrivateSubclass privateSubclass = new PrivateSubclass(); + Class targetType = privateSubclass.getClass(); + assertNotPublic(targetType); + + context.addIndexAccessor(new ReflectiveIndexAccessor(targetType, int.class, "getFruit")); + expression = parser.parseExpression("[1]"); + + String result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("fruit-1"); + + assertCanCompile(expression); + result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("fruit-1"); + } + + @Test + void privateSubclassOverridesIndexReadMethodInPrivateInterface() { + PrivateSubclass privateSubclass = new PrivateSubclass(); + Class targetType = privateSubclass.getClass(); + assertNotPublic(targetType); + + context.addIndexAccessor(new ReflectiveIndexAccessor(targetType, int.class, "getIndex")); + expression = parser.parseExpression("[1]"); + + String result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("value-1"); + + assertCanCompile(expression); + result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("value-1"); + } + + @Test + void privateSubclassOverridesIndexReadMethodInPublicSuperclass() { + PrivateSubclass privateSubclass = new PrivateSubclass(); + Class targetType = privateSubclass.getClass(); + assertNotPublic(targetType); + + context.addIndexAccessor(new ReflectiveIndexAccessor(targetType, int.class, "getIndex2")); + expression = parser.parseExpression("[2]"); + + String result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("sub-4"); // 2 * 2 + + assertCanCompile(expression); + result = expression.getValue(context, privateSubclass, String.class); + assertThat(result).isEqualTo("sub-4"); // 2 * 2 + } } + @Test void typeReference() { expression = parse("T(String)"); @@ -829,7 +1577,7 @@ void typeReference() { assertThatExceptionOfType(SpelEvaluationException.class) .isThrownBy(expression::getValue) .withMessageEndingWith("Type cannot be found 'Missing'"); - assertCantCompile(expression); + assertCannotCompile(expression); } @SuppressWarnings("unchecked") @@ -914,7 +1662,7 @@ void operatorInstanceOf_SPR14250() { ctx.setVariable("foo", String.class); expression = parse("3 instanceof #foo"); assertThat(expression.getValue(ctx)).isEqualTo(false); - assertCantCompile(expression); + assertCannotCompile(expression); // use of primitive as type for instanceof check - compilable // but always false @@ -1199,13 +1947,13 @@ void opOr() { // Can't compile this as we aren't going down the getfalse() branch in our evaluation expression = parser.parseExpression("gettrue() or getfalse()"); resultI = expression.getValue(tc, boolean.class); - assertCantCompile(expression); + assertCannotCompile(expression); expression = parser.parseExpression("getA() or getB()"); tc.a = true; tc.b = true; resultI = expression.getValue(tc, boolean.class); - assertCantCompile(expression); // Haven't yet been into second branch + assertCannotCompile(expression); // Haven't yet been into second branch tc.a = false; tc.b = true; resultI = expression.getValue(tc, boolean.class); @@ -1255,13 +2003,13 @@ void opAnd() { // Can't compile this as we aren't going down the gettrue() branch in our evaluation expression = parser.parseExpression("getfalse() and gettrue()"); resultI = expression.getValue(tc, boolean.class); - assertCantCompile(expression); + assertCannotCompile(expression); expression = parser.parseExpression("getA() and getB()"); tc.a = false; tc.b = false; resultI = expression.getValue(tc, boolean.class); - assertCantCompile(expression); // Haven't yet been into second branch + assertCannotCompile(expression); // Haven't yet been into second branch tc.a = true; tc.b = false; resultI = expression.getValue(tc, boolean.class); @@ -1329,7 +2077,7 @@ void ternary() { boolean root = true; expression = parser.parseExpression("(#root and true)?T(Integer).valueOf(1):T(Long).valueOf(3L)"); assertThat(expression.getValue(root)).isEqualTo(1); - assertCantCompile(expression); // Have not gone down false branch + assertCannotCompile(expression); // Have not gone down false branch root = false; assertThat(expression.getValue(root)).isEqualTo(3L); assertCanCompile(expression); @@ -1556,7 +2304,7 @@ void elvis() { String s = "abc"; expression = parser.parseExpression("#root?:'b'"); - assertCantCompile(expression); + assertCannotCompile(expression); resultI = expression.getValue(s, String.class); assertThat(resultI).isEqualTo("abc"); assertCanCompile(expression); @@ -1567,6 +2315,12 @@ public static String concat(String a, String b) { return a+b; } + public static String concat2(Object... args) { + return Arrays.stream(args) + .map(Objects::toString) + .collect(Collectors.joining()); + } + public static String join(String...strings) { StringBuilder buf = new StringBuilder(); for (String string: strings) { @@ -1577,9 +2331,9 @@ public static String join(String...strings) { @Test void compiledExpressionShouldWorkWhenUsingCustomFunctionWithVarargs() throws Exception { - StandardEvaluationContext context = null; + StandardEvaluationContext context; - // Here the target method takes Object... and we are passing a string + // single string argument expression = parser.parseExpression("#doFormat('hey %s', 'there')"); context = new StandardEvaluationContext(); context.registerFunction("doFormat", @@ -1587,10 +2341,11 @@ void compiledExpressionShouldWorkWhenUsingCustomFunctionWithVarargs() throws Exc ((SpelExpression) expression).setEvaluationContext(context); assertThat(expression.getValue(String.class)).isEqualTo("hey there"); - assertThat(((SpelNodeImpl) ((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(String.class)).isEqualTo("hey there"); + // single string argument from root array access expression = parser.parseExpression("#doFormat([0], 'there')"); context = new StandardEvaluationContext(new Object[] {"hey %s"}); context.registerFunction("doFormat", @@ -1598,10 +2353,11 @@ void compiledExpressionShouldWorkWhenUsingCustomFunctionWithVarargs() throws Exc ((SpelExpression) expression).setEvaluationContext(context); assertThat(expression.getValue(String.class)).isEqualTo("hey there"); - assertThat(((SpelNodeImpl) ((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(String.class)).isEqualTo("hey there"); + // single string from variable expression = parser.parseExpression("#doFormat([0], #arg)"); context = new StandardEvaluationContext(new Object[] {"hey %s"}); context.registerFunction("doFormat", @@ -1610,7 +2366,19 @@ void compiledExpressionShouldWorkWhenUsingCustomFunctionWithVarargs() throws Exc ((SpelExpression) expression).setEvaluationContext(context); assertThat(expression.getValue(String.class)).isEqualTo("hey there"); - assertThat(((SpelNodeImpl) ((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); + assertCanCompile(expression); + assertThat(expression.getValue(String.class)).isEqualTo("hey there"); + + // string array argument + expression = parser.parseExpression("#doFormat('hey %s', #arg)"); + context = new StandardEvaluationContext(); + context.registerFunction("doFormat", + DelegatingStringFormat.class.getDeclaredMethod("format", String.class, Object[].class)); + context.setVariable("arg", new String[] { "there" }); + ((SpelExpression) expression).setEvaluationContext(context); + assertThat(expression.getValue(String.class)).isEqualTo("hey there"); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(String.class)).isEqualTo("hey there"); } @@ -1619,7 +2387,11 @@ void compiledExpressionShouldWorkWhenUsingCustomFunctionWithVarargs() throws Exc void functionReference() throws Exception { EvaluationContext ctx = new StandardEvaluationContext(); Method m = getClass().getDeclaredMethod("concat", String.class, String.class); - ctx.setVariable("concat",m); + ctx.setVariable("concat", m); + Method m2 = getClass().getDeclaredMethod("concat2", Object[].class); + ctx.setVariable("concat2", m2); + Method m3 = getClass().getDeclaredMethod("join", String[].class); + ctx.setVariable("join", m3); expression = parser.parseExpression("#concat('a','b')"); assertThat(expression.getValue(ctx)).isEqualTo("ab"); @@ -1631,6 +2403,20 @@ void functionReference() throws Exception { assertCanCompile(expression); assertThat(expression.getValue(ctx)).isEqualTo('b'); + // varargs + expression = parser.parseExpression("#join(#stringArray)"); + ctx.setVariable("stringArray", new String[] { "a", "b", "c" }); + assertThat(expression.getValue(ctx)).isEqualTo("abc"); + assertCanCompile(expression); + assertThat(expression.getValue(ctx)).isEqualTo("abc"); + + // varargs with argument component type that is a subtype of the varargs component type. + expression = parser.parseExpression("#concat2(#stringArray)"); + ctx.setVariable("stringArray", new String[] { "a", "b", "c" }); + assertThat(expression.getValue(ctx)).isEqualTo("abc"); + assertCanCompile(expression); + assertThat(expression.getValue(ctx)).isEqualTo("abc"); + expression = parser.parseExpression("#concat(#a,#b)"); ctx.setVariable("a", "foo"); ctx.setVariable("b", "bar"); @@ -1712,7 +2498,7 @@ void functionReferenceVisibility_SPR12359() throws Exception { // type nor method are public expression = parser.parseExpression("#doCompare([0],#arg)"); assertThat(expression.getValue(context, Integer.class).toString()).isEqualTo("-1"); - assertCantCompile(expression); + assertCannotCompile(expression); // type not public but method is context = new StandardEvaluationContext(new Object[] {"1"}); @@ -1721,7 +2507,7 @@ void functionReferenceVisibility_SPR12359() throws Exception { context.setVariable("arg", "2"); expression = parser.parseExpression("#doCompare([0],#arg)"); assertThat(expression.getValue(context, Integer.class).toString()).isEqualTo("-1"); - assertCantCompile(expression); + assertCannotCompile(expression); } @Test @@ -1736,7 +2522,7 @@ void functionReferenceNonCompilableArguments_SPR12359() throws Exception { expression = parser.parseExpression("#negate(#ints.?[#this<2][0])"); assertThat(expression.getValue(context, Integer.class).toString()).isEqualTo("-1"); // Selection isn't compilable. - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isFalse(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isFalse(); } @Test @@ -1765,173 +2551,170 @@ void functionReferenceVarargs_SPR12359() throws Exception { expression = parser.parseExpression("#append('a','b','c')"); assertThat(expression.getValue(context).toString()).isEqualTo("abc"); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context).toString()).isEqualTo("abc"); expression = parser.parseExpression("#append('a')"); assertThat(expression.getValue(context).toString()).isEqualTo("a"); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context).toString()).isEqualTo("a"); expression = parser.parseExpression("#append()"); assertThat(expression.getValue(context).toString()).isEmpty(); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context).toString()).isEmpty(); expression = parser.parseExpression("#append(#stringArray)"); assertThat(expression.getValue(context).toString()).isEqualTo("xyz"); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context).toString()).isEqualTo("xyz"); // This is a methodreference invocation, to compare with functionreference expression = parser.parseExpression("append(#stringArray)"); assertThat(expression.getValue(context, new SomeCompareMethod2()).toString()).isEqualTo("xyz"); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context, new SomeCompareMethod2()).toString()).isEqualTo("xyz"); expression = parser.parseExpression("#append2('a','b','c')"); assertThat(expression.getValue(context).toString()).isEqualTo("abc"); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context).toString()).isEqualTo("abc"); expression = parser.parseExpression("append2('a','b')"); assertThat(expression.getValue(context, new SomeCompareMethod2()).toString()).isEqualTo("ab"); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context, new SomeCompareMethod2()).toString()).isEqualTo("ab"); expression = parser.parseExpression("#append2('a','b')"); assertThat(expression.getValue(context).toString()).isEqualTo("ab"); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context).toString()).isEqualTo("ab"); expression = parser.parseExpression("#append2()"); assertThat(expression.getValue(context).toString()).isEmpty(); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context).toString()).isEmpty(); expression = parser.parseExpression("#append3(#stringArray)"); assertThat(expression.getValue(context, new SomeCompareMethod2()).toString()).isEqualTo("xyz"); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context, new SomeCompareMethod2()).toString()).isEqualTo("xyz"); - // TODO fails due to conversionservice handling of String[] to Object... - // expression = parser.parseExpression("#append2(#stringArray)"); - // assertEquals("xyz", expression.getValue(context).toString()); - // assertTrue(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()); - // assertCanCompile(expression); - // assertEquals("xyz", expression.getValue(context).toString()); + expression = parser.parseExpression("#append2(#stringArray)"); + assertThat(expression.getValue(context)).hasToString("xyz"); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); + assertCanCompile(expression); + assertThat(expression.getValue(context)).hasToString("xyz"); expression = parser.parseExpression("#sum(1,2,3)"); assertThat(expression.getValue(context)).isEqualTo(6); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context)).isEqualTo(6); expression = parser.parseExpression("#sum(2)"); assertThat(expression.getValue(context)).isEqualTo(2); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context)).isEqualTo(2); expression = parser.parseExpression("#sum()"); assertThat(expression.getValue(context)).isEqualTo(0); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context)).isEqualTo(0); expression = parser.parseExpression("#sum(#intArray)"); assertThat(expression.getValue(context)).isEqualTo(20); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context)).isEqualTo(20); expression = parser.parseExpression("#sumDouble(1.0d,2.0d,3.0d)"); assertThat(expression.getValue(context)).isEqualTo(6); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context)).isEqualTo(6); expression = parser.parseExpression("#sumDouble(2.0d)"); assertThat(expression.getValue(context)).isEqualTo(2); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context)).isEqualTo(2); expression = parser.parseExpression("#sumDouble()"); assertThat(expression.getValue(context)).isEqualTo(0); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context)).isEqualTo(0); expression = parser.parseExpression("#sumDouble(#doubleArray)"); assertThat(expression.getValue(context)).isEqualTo(20); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context)).isEqualTo(20); expression = parser.parseExpression("#sumFloat(1.0f,2.0f,3.0f)"); assertThat(expression.getValue(context)).isEqualTo(6); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context)).isEqualTo(6); expression = parser.parseExpression("#sumFloat(2.0f)"); assertThat(expression.getValue(context)).isEqualTo(2); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context)).isEqualTo(2); expression = parser.parseExpression("#sumFloat()"); assertThat(expression.getValue(context)).isEqualTo(0); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context)).isEqualTo(0); expression = parser.parseExpression("#sumFloat(#floatArray)"); assertThat(expression.getValue(context)).isEqualTo(20); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context)).isEqualTo(20); - expression = parser.parseExpression("#appendChar('abc'.charAt(0),'abc'.charAt(1))"); assertThat(expression.getValue(context)).isEqualTo("ab"); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context)).isEqualTo("ab"); - expression = parser.parseExpression("#append4('a','b','c')"); assertThat(expression.getValue(context).toString()).isEqualTo("a::bc"); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context).toString()).isEqualTo("a::bc"); expression = parser.parseExpression("#append4('a','b')"); assertThat(expression.getValue(context).toString()).isEqualTo("a::b"); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context).toString()).isEqualTo("a::b"); expression = parser.parseExpression("#append4('a')"); assertThat(expression.getValue(context).toString()).isEqualTo("a::"); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context).toString()).isEqualTo("a::"); expression = parser.parseExpression("#append4('a',#stringArray)"); assertThat(expression.getValue(context).toString()).isEqualTo("a::xyz"); - assertThat(((SpelNodeImpl)((SpelExpression) expression).getAST()).isCompilable()).isTrue(); + assertThat(((SpelExpression) expression).getAST().isCompilable()).isTrue(); assertCanCompile(expression); assertThat(expression.getValue(context).toString()).isEqualTo("a::xyz"); } @@ -1979,7 +2762,7 @@ void opLt() { // Differing types of number, not yet supported expression = parse("1 < 3.0d"); - assertCantCompile(expression); + assertCannotCompile(expression); expression = parse("T(Integer).valueOf(3) < 4"); assertThat((Boolean) expression.getValue()).isTrue(); @@ -2038,7 +2821,7 @@ void opLe() { // Differing types of number, not yet supported expression = parse("1 <= 3.0d"); - assertCantCompile(expression); + assertCannotCompile(expression); expression = parse("T(Integer).valueOf(3) <= 4"); assertThat((Boolean) expression.getValue()).isTrue(); @@ -2088,7 +2871,7 @@ void opGt() { // Differing types of number, not yet supported expression = parse("1 > 3.0d"); - assertCantCompile(expression); + assertCannotCompile(expression); expression = parse("T(Integer).valueOf(3) > 4"); assertThat((Boolean) expression.getValue()).isFalse(); @@ -2150,7 +2933,7 @@ void opGe() { // Differing types of number, not yet supported expression = parse("1 >= 3.0d"); - assertCantCompile(expression); + assertCannotCompile(expression); expression = parse("T(Integer).valueOf(3) >= 4"); assertThat((Boolean) expression.getValue()).isFalse(); @@ -2230,7 +3013,7 @@ void opEq() { // number types are not the same expression = parse("1 == 3.0d"); - assertCantCompile(expression); + assertCannotCompile(expression); Double d = 3.0d; expression = parse("#root==3.0d"); @@ -2375,7 +3158,7 @@ void opNe() { // not compatible number types expression = parse("1 != 3.0d"); - assertCantCompile(expression); + assertCannotCompile(expression); expression = parse("T(Integer).valueOf(3) != 4"); assertThat((Boolean) expression.getValue()).isTrue(); @@ -2520,7 +3303,6 @@ void opEq_SPR14863() { assertThat(b).isTrue(); assertThat(aa.gotComparedTo).isEqualTo(bb); - List ls = new ArrayList<>(); ls.add("foo"); StandardEvaluationContext context = new StandardEvaluationContext(ls); @@ -3047,7 +3829,7 @@ void opPlusString() { // Three strings, optimal bytecode would only use one StringBuilder expression = parse("'hello' + 3 + ' spring'"); assertThat(expression.getValue(new Greeter())).isEqualTo("hello3 spring"); - assertCantCompile(expression); + assertCannotCompile(expression); expression = parse("object + 'a'"); assertThat(expression.getValue(new Greeter())).isEqualTo("objecta"); @@ -3943,7 +4725,6 @@ void failsWhenSettingContextForExpression_SPR12326() { assertThat(expression.getValue(Boolean.class)).isNull(); } - /** * Test variants of using T(...) and static/non-static method/property/field references. */ @@ -4036,7 +4817,6 @@ void constructorReference_SPR13781() { assertThat(expression.getValue(StaticsHelper.sh)).isEqualTo("mb"); assertCanCompile(expression); assertThat(expression.getValue(StaticsHelper.sh)).isEqualTo("mb"); - } @Test @@ -4124,65 +4904,90 @@ void methodReferenceMissingCastAndRootObjectAccessing_SPR12326() { context = new StandardEvaluationContext(new Object[] {person.getAge()}); context.setVariable("it", person); assertThat(ex.getValue(context, Boolean.class)).isTrue(); - assertThat(ex.getValue(context, Boolean.class)).isTrue(); PersonInOtherPackage person2 = new PersonInOtherPackage(1); ex = parser.parseRaw("#it?.age.equals([0])"); context = new StandardEvaluationContext(new Object[] {person2.getAge()}); context.setVariable("it", person2); assertThat(ex.getValue(context, Boolean.class)).isTrue(); - assertThat(ex.getValue(context, Boolean.class)).isTrue(); ex = parser.parseRaw("#it?.age.equals([0])"); context = new StandardEvaluationContext(new Object[] {person2.getAge()}); context.setVariable("it", person2); assertThat((Boolean) ex.getValue(context)).isTrue(); - assertThat((Boolean) ex.getValue(context)).isTrue(); } @Test void constructorReference() { - // simple ctor + // simple constructor expression = parser.parseExpression("new String('123')"); assertThat(expression.getValue()).isEqualTo("123"); assertCanCompile(expression); assertThat(expression.getValue()).isEqualTo("123"); - String testclass8 = "org.springframework.expression.spel.SpelCompilationCoverageTests$TestClass8"; - // multi arg ctor that includes primitives + String testclass8 = TestClass8.class.getName(); + Object result; + + // multi arg constructor that includes primitives expression = parser.parseExpression("new " + testclass8 + "(42,'123',4.0d,true)"); - assertThat(expression.getValue().getClass().getName()).isEqualTo(testclass8); + result = expression.getValue(); + assertThat(result).isExactlyInstanceOf(TestClass8.class); assertCanCompile(expression); - Object o = expression.getValue(); - assertThat(o.getClass().getName()).isEqualTo(testclass8); - TestClass8 tc8 = (TestClass8) o; + result = expression.getValue(); + assertThat(result).isExactlyInstanceOf(TestClass8.class); + TestClass8 tc8 = (TestClass8) result; assertThat(tc8.i).isEqualTo(42); assertThat(tc8.s).isEqualTo("123"); assertThat(tc8.d).isCloseTo(4.0d, within(0.5d)); assertThat(tc8.z).isTrue(); - // no-arg ctor + // no-arg constructor expression = parser.parseExpression("new " + testclass8 + "()"); - assertThat(expression.getValue().getClass().getName()).isEqualTo(testclass8); + result = expression.getValue(); + assertThat(result).isExactlyInstanceOf(TestClass8.class); assertCanCompile(expression); - o = expression.getValue(); - assertThat(o.getClass().getName()).isEqualTo(testclass8); + result = expression.getValue(); + assertThat(result).isExactlyInstanceOf(TestClass8.class); - // pass primitive to reference type ctor + // pass primitive to reference type constructor expression = parser.parseExpression("new " + testclass8 + "(42)"); - assertThat(expression.getValue().getClass().getName()).isEqualTo(testclass8); + result = expression.getValue(); + assertThat(result).isExactlyInstanceOf(TestClass8.class); assertCanCompile(expression); - o = expression.getValue(); - assertThat(o.getClass().getName()).isEqualTo(testclass8); - tc8 = (TestClass8) o; + result = expression.getValue(); + assertThat(result).isExactlyInstanceOf(TestClass8.class); + tc8 = (TestClass8) result; assertThat(tc8.i).isEqualTo(42); + // varargs + expression = parser.parseExpression("new " + testclass8 + "(#root)"); + Object[] objectArray = { "a", "b", "c" }; + result = expression.getValue(objectArray); + assertThat(result).isExactlyInstanceOf(TestClass8.class); + assertCanCompile(expression); + result = expression.getValue(objectArray); + assertThat(result).isExactlyInstanceOf(TestClass8.class); + tc8 = (TestClass8) result; + assertThat(tc8.args).containsExactly("a", "b", "c"); + + // varargs with argument component type that is a subtype of the varargs component type. + expression = parser.parseExpression("new " + testclass8 + "(#root)"); + String[] stringArray = { "a", "b", "c" }; + result = expression.getValue(stringArray); + assertThat(result).isExactlyInstanceOf(TestClass8.class); + assertCanCompile(expression); + result = expression.getValue(stringArray); + assertThat(result).isExactlyInstanceOf(TestClass8.class); + tc8 = (TestClass8) result; + assertThat(tc8.args).containsExactly("a", "b", "c"); + // private class, can't compile it - String testclass9 = "org.springframework.expression.spel.SpelCompilationCoverageTests$TestClass9"; + String testclass9 = TestClass9.class.getName(); expression = parser.parseExpression("new " + testclass9 + "(42)"); - assertThat(expression.getValue().getClass().getName()).isEqualTo(testclass9); - assertCantCompile(expression); + result = expression.getValue(); + assertThat(result).isExactlyInstanceOf(TestClass9.class); + assertCannotCompile(expression); } @Test @@ -4192,7 +4997,7 @@ void methodReferenceReflectiveMethodSelectionWithVarargs() { // Should call the non varargs version of concat // (which causes the '::' prefix in test output) expression = parser.parseExpression("concat('test')"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("::test"); assertCanCompile(expression); @@ -4203,7 +5008,7 @@ void methodReferenceReflectiveMethodSelectionWithVarargs() { // This will call the varargs concat with an empty array expression = parser.parseExpression("concat()"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEmpty(); assertCanCompile(expression); @@ -4215,7 +5020,7 @@ void methodReferenceReflectiveMethodSelectionWithVarargs() { // Should call the non varargs version of concat // (which causes the '::' prefix in test output) expression = parser.parseExpression("concat2('test')"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("::test"); assertCanCompile(expression); @@ -4226,7 +5031,7 @@ void methodReferenceReflectiveMethodSelectionWithVarargs() { // This will call the varargs concat with an empty array expression = parser.parseExpression("concat2()"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEmpty(); assertCanCompile(expression); @@ -4242,7 +5047,7 @@ void methodReferenceVarargs() { // varargs string expression = parser.parseExpression("eleven()"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEmpty(); assertCanCompile(expression); @@ -4253,7 +5058,7 @@ void methodReferenceVarargs() { // varargs string expression = parser.parseExpression("eleven('aaa')"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("aaa"); assertCanCompile(expression); @@ -4264,7 +5069,7 @@ void methodReferenceVarargs() { // varargs string expression = parser.parseExpression("eleven(stringArray)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("aaabbbccc"); assertCanCompile(expression); @@ -4275,7 +5080,7 @@ void methodReferenceVarargs() { // varargs string expression = parser.parseExpression("eleven('aaa','bbb','ccc')"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("aaabbbccc"); assertCanCompile(expression); @@ -4284,8 +5089,9 @@ void methodReferenceVarargs() { assertThat(tc.s).isEqualTo("aaabbbccc"); tc.reset(); + // varargs object expression = parser.parseExpression("sixteen('aaa','bbb','ccc')"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("aaabbbccc"); assertCanCompile(expression); @@ -4294,31 +5100,42 @@ void methodReferenceVarargs() { assertThat(tc.s).isEqualTo("aaabbbccc"); tc.reset(); + // string array from property in varargs object expression = parser.parseExpression("sixteen(seventeen)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("aaabbbccc"); assertCanCompile(expression); tc.reset(); - // see TODO below - // expression.getValue(tc); - // assertThat(tc.s).isEqualTo("aaabbbccc"); - // tc.reset(); - - // TODO Determine why the String[] is passed as the first element of the Object... varargs array instead of the entire varargs array. - // expression = parser.parseExpression("sixteen(stringArray)"); - // assertCantCompile(expression); - // expression.getValue(tc); - // assertThat(tc.s).isEqualTo("aaabbbccc"); - // assertCanCompile(expression); - // tc.reset(); - // expression.getValue(tc); - // assertThat(tc.s).isEqualTo("aaabbbccc"); - // tc.reset(); + expression.getValue(tc); + assertThat(tc.s).isEqualTo("aaabbbccc"); + tc.reset(); + + // string array from variable in varargs object + expression = parser.parseExpression("sixteen(stringArray)"); + assertCannotCompile(expression); + expression.getValue(tc); + assertThat(tc.s).isEqualTo("aaabbbccc"); + assertCanCompile(expression); + tc.reset(); + expression.getValue(tc); + assertThat(tc.s).isEqualTo("aaabbbccc"); + tc.reset(); + + // string array in varargs object with other parameter + expression = parser.parseExpression("eighteen('AAA', stringArray)"); + assertCannotCompile(expression); + expression.getValue(tc); + assertThat(tc.s).isEqualTo("AAA::aaabbbccc"); + assertCanCompile(expression); + tc.reset(); + expression.getValue(tc); + assertThat(tc.s).isEqualTo("AAA::aaabbbccc"); + tc.reset(); // varargs int expression = parser.parseExpression("twelve(1,2,3)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.i).isEqualTo(6); assertCanCompile(expression); @@ -4328,7 +5145,7 @@ void methodReferenceVarargs() { tc.reset(); expression = parser.parseExpression("twelve(1)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.i).isEqualTo(1); assertCanCompile(expression); @@ -4339,7 +5156,7 @@ void methodReferenceVarargs() { // one string then varargs string expression = parser.parseExpression("thirteen('aaa','bbb','ccc')"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("aaa::bbbccc"); assertCanCompile(expression); @@ -4350,7 +5167,7 @@ void methodReferenceVarargs() { // nothing passed to varargs parameter expression = parser.parseExpression("thirteen('aaa')"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("aaa::"); assertCanCompile(expression); @@ -4361,7 +5178,7 @@ void methodReferenceVarargs() { // nested arrays expression = parser.parseExpression("fourteen('aaa',stringArray,stringArray)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("aaa::{aaabbbccc}{aaabbbccc}"); assertCanCompile(expression); @@ -4372,7 +5189,7 @@ void methodReferenceVarargs() { // nested primitive array expression = parser.parseExpression("fifteen('aaa',intArray,intArray)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("aaa::{112233}{112233}"); assertCanCompile(expression); @@ -4383,7 +5200,7 @@ void methodReferenceVarargs() { // varargs boolean expression = parser.parseExpression("arrayz(true,true,false)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("truetruefalse"); assertCanCompile(expression); @@ -4393,7 +5210,7 @@ void methodReferenceVarargs() { tc.reset(); expression = parser.parseExpression("arrayz(true)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("true"); assertCanCompile(expression); @@ -4404,7 +5221,7 @@ void methodReferenceVarargs() { // varargs short expression = parser.parseExpression("arrays(s1,s2,s3)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("123"); assertCanCompile(expression); @@ -4414,7 +5231,7 @@ void methodReferenceVarargs() { tc.reset(); expression = parser.parseExpression("arrays(s1)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("1"); assertCanCompile(expression); @@ -4425,7 +5242,7 @@ void methodReferenceVarargs() { // varargs double expression = parser.parseExpression("arrayd(1.0d,2.0d,3.0d)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("1.02.03.0"); assertCanCompile(expression); @@ -4435,7 +5252,7 @@ void methodReferenceVarargs() { tc.reset(); expression = parser.parseExpression("arrayd(1.0d)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("1.0"); assertCanCompile(expression); @@ -4446,7 +5263,7 @@ void methodReferenceVarargs() { // varargs long expression = parser.parseExpression("arrayj(l1,l2,l3)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("123"); assertCanCompile(expression); @@ -4456,7 +5273,7 @@ void methodReferenceVarargs() { tc.reset(); expression = parser.parseExpression("arrayj(l1)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("1"); assertCanCompile(expression); @@ -4467,7 +5284,7 @@ void methodReferenceVarargs() { // varargs char expression = parser.parseExpression("arrayc(c1,c2,c3)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("abc"); assertCanCompile(expression); @@ -4477,7 +5294,7 @@ void methodReferenceVarargs() { tc.reset(); expression = parser.parseExpression("arrayc(c1)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("a"); assertCanCompile(expression); @@ -4488,7 +5305,7 @@ void methodReferenceVarargs() { // varargs byte expression = parser.parseExpression("arrayb(b1,b2,b3)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("656667"); assertCanCompile(expression); @@ -4498,7 +5315,7 @@ void methodReferenceVarargs() { tc.reset(); expression = parser.parseExpression("arrayb(b1)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("65"); assertCanCompile(expression); @@ -4509,7 +5326,7 @@ void methodReferenceVarargs() { // varargs float expression = parser.parseExpression("arrayf(f1,f2,f3)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("1.02.03.0"); assertCanCompile(expression); @@ -4519,7 +5336,7 @@ void methodReferenceVarargs() { tc.reset(); expression = parser.parseExpression("arrayf(f1)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("1.0"); assertCanCompile(expression); @@ -4534,7 +5351,7 @@ public void nullSafeInvocationOfNonStaticVoidMethod() { // non-static method, no args, void return expression = parser.parseExpression("new %s()?.one()".formatted(TestClass5.class.getName())); - assertCantCompile(expression); + assertCannotCompile(expression); TestClass5._i = 0; assertThat(expression.getValue()).isNull(); @@ -4551,7 +5368,7 @@ public void nullSafeInvocationOfStaticVoidMethod() { // static method, no args, void return expression = parser.parseExpression("T(%s)?.two()".formatted(TestClass5.class.getName())); - assertCantCompile(expression); + assertCannotCompile(expression); TestClass5._i = 0; assertThat(expression.getValue()).isNull(); @@ -4568,7 +5385,7 @@ public void nullSafeInvocationOfNonStaticVoidWrapperMethod() { // non-static method, no args, Void return expression = parser.parseExpression("new %s()?.oneVoidWrapper()".formatted(TestClass5.class.getName())); - assertCantCompile(expression); + assertCannotCompile(expression); TestClass5._i = 0; assertThat(expression.getValue()).isNull(); @@ -4585,7 +5402,7 @@ public void nullSafeInvocationOfStaticVoidWrapperMethod() { // static method, no args, Void return expression = parser.parseExpression("T(%s)?.twoVoidWrapper()".formatted(TestClass5.class.getName())); - assertCantCompile(expression); + assertCannotCompile(expression); TestClass5._i = 0; assertThat(expression.getValue()).isNull(); @@ -4603,7 +5420,7 @@ void methodReference() { // non-static method, no args, void return expression = parser.parseExpression("one()"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertCanCompile(expression); tc.reset(); @@ -4613,7 +5430,7 @@ void methodReference() { // static method, no args, void return expression = parser.parseExpression("two()"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertCanCompile(expression); tc.reset(); @@ -4623,7 +5440,7 @@ void methodReference() { // non-static method, reference type return expression = parser.parseExpression("three()"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertCanCompile(expression); tc.reset(); @@ -4632,7 +5449,7 @@ void methodReference() { // non-static method, primitive type return expression = parser.parseExpression("four()"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertCanCompile(expression); tc.reset(); @@ -4641,7 +5458,7 @@ void methodReference() { // static method, reference type return expression = parser.parseExpression("five()"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertCanCompile(expression); tc.reset(); @@ -4650,7 +5467,7 @@ void methodReference() { // static method, primitive type return expression = parser.parseExpression("six()"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertCanCompile(expression); tc.reset(); @@ -4659,7 +5476,7 @@ void methodReference() { // non-static method, one parameter of reference type expression = parser.parseExpression("seven(\"foo\")"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertCanCompile(expression); tc.reset(); @@ -4669,7 +5486,7 @@ void methodReference() { // static method, one parameter of reference type expression = parser.parseExpression("eight(\"bar\")"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertCanCompile(expression); tc.reset(); @@ -4679,7 +5496,7 @@ void methodReference() { // non-static method, one parameter of primitive type expression = parser.parseExpression("nine(231)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertCanCompile(expression); tc.reset(); @@ -4689,7 +5506,7 @@ void methodReference() { // static method, one parameter of primitive type expression = parser.parseExpression("ten(111)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertCanCompile(expression); tc.reset(); @@ -4701,10 +5518,10 @@ void methodReference() { // Converting from an int to a string expression = parser.parseExpression("seven(123)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("123"); - assertCantCompile(expression); // Uncompilable as argument conversion is occurring + assertCannotCompile(expression); // Uncompilable as argument conversion is occurring Expression expression = parser.parseExpression("'abcd'.substring(index1,index2)"); String resultI = expression.getValue(new TestClass1(), String.class); @@ -4715,7 +5532,7 @@ void methodReference() { // Converting from an int to a Number expression = parser.parseExpression("takeNumber(123)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("123"); tc.reset(); @@ -4725,7 +5542,7 @@ void methodReference() { // Passing a subtype expression = parser.parseExpression("takeNumber(T(Integer).valueOf(42))"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("42"); tc.reset(); @@ -4735,11 +5552,11 @@ void methodReference() { // Passing a subtype expression = parser.parseExpression("takeString(T(Integer).valueOf(42))"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("42"); tc.reset(); - assertCantCompile(expression); // method takes a string and we are passing an Integer + assertCannotCompile(expression); // method takes a string and we are passing an Integer } @Test @@ -4766,7 +5583,7 @@ void errorHandling() { tc.field = "foo"; expression = parser.parseExpression("seven(field)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("foo"); assertCanCompile(expression); @@ -4777,7 +5594,7 @@ void errorHandling() { // method with changing parameter types (change reference type) tc.obj = "foo"; expression = parser.parseExpression("seven(obj)"); - assertCantCompile(expression); + assertCannotCompile(expression); expression.getValue(tc); assertThat(tc.s).isEqualTo("foo"); assertCanCompile(expression); @@ -4913,35 +5730,35 @@ void propertyReference() { // non-static field expression = parser.parseExpression("orange"); - assertCantCompile(expression); + assertCannotCompile(expression); assertThat(expression.getValue(tc)).isEqualTo("value1"); assertCanCompile(expression); assertThat(expression.getValue(tc)).isEqualTo("value1"); // static field expression = parser.parseExpression("apple"); - assertCantCompile(expression); + assertCannotCompile(expression); assertThat(expression.getValue(tc)).isEqualTo("value2"); assertCanCompile(expression); assertThat(expression.getValue(tc)).isEqualTo("value2"); // non static getter expression = parser.parseExpression("banana"); - assertCantCompile(expression); + assertCannotCompile(expression); assertThat(expression.getValue(tc)).isEqualTo("value3"); assertCanCompile(expression); assertThat(expression.getValue(tc)).isEqualTo("value3"); // static getter expression = parser.parseExpression("plum"); - assertCantCompile(expression); + assertCannotCompile(expression); assertThat(expression.getValue(tc)).isEqualTo("value4"); assertCanCompile(expression); assertThat(expression.getValue(tc)).isEqualTo("value4"); // record-style accessor expression = parser.parseExpression("strawberry"); - assertCantCompile(expression); + assertCannotCompile(expression); assertThat(expression.getValue(tc)).isEqualTo("value5"); assertCanCompile(expression); assertThat(expression.getValue(tc)).isEqualTo("value5"); @@ -5005,7 +5822,7 @@ void mixingItUp_propertyAccessIndexerOpLtTernaryRootNull() { void variantGetter() { Payload2Holder holder = new Payload2Holder(); StandardEvaluationContext ctx = new StandardEvaluationContext(); - ctx.addPropertyAccessor(new MyAccessor()); + ctx.addPropertyAccessor(new MyPropertyAccessor()); expression = parser.parseExpression("payload2.var1"); Object v = expression.getValue(ctx,holder); assertThat(v).isEqualTo("abc"); @@ -5562,7 +6379,7 @@ private void assertCanCompile(Expression expression) { .isTrue(); } - private void assertCantCompile(Expression expression) { + private void assertCannotCompile(Expression expression) { assertThat(SpelCompiler.compile(expression)) .as(() -> "Expression <%s> should not be compilable" .formatted(((SpelExpression) expression).toStringAST())) @@ -5573,6 +6390,10 @@ private Expression parse(String expression) { return parser.parseExpression(expression); } + private static void assertNotPublic(Class clazz) { + assertThat(Modifier.isPublic(clazz.getModifiers())).as("%s must be private", clazz.getName()).isFalse(); + } + // Nested types @@ -5653,7 +6474,7 @@ public T getPayload() { } - static class MyAccessor implements CompilablePropertyAccessor { + static class MyPropertyAccessor implements CompilablePropertyAccessor { private Method method; @@ -5787,25 +6608,7 @@ public static class Payload2Holder { } - public class Person { - - private int age; - - public Person(int age) { - this.age = age; - } - - public int getAge() { - return age; - } - - public void setAge(int age) { - this.age = age; - } - } - - - public class Person3 { + public static class Person3 { private int age; @@ -6177,6 +6980,18 @@ public void sixteen(Object... vargs) { public String[] seventeen() { return new String[] { "aaa", "bbb", "ccc" }; } + + public void eighteen(String a, Object... vargs) { + if (vargs == null) { + s = a + "::"; + } + else { + s = a + "::"; + for (Object varg: vargs) { + s += varg; + } + } + } } @@ -6225,6 +7040,7 @@ public static class TestClass8 { public String s; public double d; public boolean z; + public Object[] args; public TestClass8(int i, String s, double d, boolean z) { this.i = i; @@ -6240,6 +7056,10 @@ public TestClass8(Integer i) { this.i = i; } + public TestClass8(Object... args) { + this.args = args; + } + @SuppressWarnings("unused") private TestClass8(String a, String b) { this.s = a+b; @@ -6603,20 +7423,177 @@ public void setValue2(Integer value) { } } - // NOTE: saveGeneratedClassFile() can be copied to SpelCompiler and uncommented - // at the end of createExpressionClass(SpelNodeImpl) in order to review generated - // byte code for debugging purposes. - // - // private static void saveGeneratedClassFile(String stringAST, String className, byte[] data) { - // try { - // Path path = Path.of("build", StringUtils.replace(className, "/", ".") + ".class"); - // Files.deleteIfExists(path); - // System.out.println("Writing compiled SpEL expression [%s] to [%s]".formatted(stringAST, path.toAbsolutePath())); - // Files.copy(new ByteArrayInputStream(data), path); - // } - // catch (IOException ex) { - // throw new UncheckedIOException(ex); - // } - // } + private interface PrivateInterface { + + String getMessage(); + + String getIndex(int index); + } + + private static class PrivateSubclass extends PublicSuperclass implements PublicInterface, PrivateInterface { + + @Override + public int getNumber() { + return 2; + } + + @Override + public String getText() { + return "enigma"; + } + + @Override + public String getMessage() { + return "hello"; + } + + @Override + public String getIndex2(int index) { + return "sub-" + (2 * index); + } + + @Override + public String getFruit(int index) { + return "fruit-" + index; + } + } + + // Must be public with public fields/properties. + public static class RootContextWithIndexedProperties { + public int[] intArray; + public Number[] numberArray; + public List list; + public Set set; + public String string; + public Map map; + public Person person; + } + + /** + * Type that can be indexed by an int or an Integer and whose indexed values + * are enums. + */ + public static class Colors { + + private final Map map = new HashMap<>(); + + { + this.map.put(1, Color.BLUE); + this.map.put(2, Color.GREEN); + this.map.put(3, Color.ORANGE); + this.map.put(42, Color.PURPLE); + } + + public Color get(int index) { + if (!this.map.containsKey(index)) { + throw new IndexOutOfBoundsException("No color for index " + index); + } + return this.map.get(index); + } + + public void set(int index, Color color) { + this.map.put(index, color); + } + } + + /** + * {@link CompilableIndexAccessor} that knows how to index into {@link Colors}. + */ + private static class ColorsIndexAccessor extends ReflectiveIndexAccessor { + + ColorsIndexAccessor() { + super(Colors.class, int.class, "get", "set"); + } + } + + /** + * Type that can be indexed by an enum and whose indexed values are primitive + * integers. + */ + public static class ColorOrdinals { + + public int get(Color color) { + return color.ordinal(); + } + } + + /** + * {@link CompilableIndexAccessor} that knows how to index into {@link ColorOrdinals}. + */ + private static class ColorOrdinalsIndexAccessor extends ReflectiveIndexAccessor { + + ColorOrdinalsIndexAccessor() { + super(ColorOrdinals.class, Color.class, "get"); + } + } + + /** + * Manually implemented {@link CompilableIndexAccessor} that knows how to + * index into {@link FruitMap} for reading, writing, and compilation. + */ + private static class FruitMapIndexAccessor implements CompilableIndexAccessor { + + private final Method method = ReflectionUtils.findMethod(FruitMap.class, "getFruit", Color.class); + + private final String targetTypeDesc = CodeFlow.toDescriptor(FruitMap.class); + + private final String classDesc = this.targetTypeDesc.substring(1); + + private final String methodDescr = CodeFlow.createSignatureDescriptor(this.method); + + + @Override + public Class[] getSpecificTargetClasses() { + return new Class[] { FruitMap.class }; + } + + @Override + public boolean canRead(EvaluationContext context, Object target, Object index) { + return (target instanceof FruitMap && index instanceof Color); + } + + @Override + public TypedValue read(EvaluationContext context, Object target, Object index) { + FruitMap fruitMap = (FruitMap) target; + Color color = (Color) index; + return new TypedValue(fruitMap.getFruit(color)); + } + + @Override + public boolean canWrite(EvaluationContext context, Object target, Object index) { + return canRead(context, target, index); + } + + @Override + public void write(EvaluationContext context, Object target, Object index, @Nullable Object newValue) { + FruitMap fruitMap = (FruitMap) target; + Color color = (Color) index; + String fruit = String.valueOf(newValue); + fruitMap.setFruit(color, fruit); + } + + @Override + public boolean isCompilable() { + return true; + } + + @Override + public Class getIndexedValueType() { + return String.class; + } + + @Override + public void generateCode(SpelNode index, MethodVisitor mv, CodeFlow cf) { + String lastDesc = cf.lastDescriptor(); + // Ensure the current object on the stack is the target type. + if (lastDesc == null || !lastDesc.equals(this.targetTypeDesc)) { + CodeFlow.insertCheckCast(mv, this.targetTypeDesc); + } + // Push the index onto the stack. + cf.generateCodeForArgument(mv, index, Color.class); + // Invoke the read-method. + mv.visitMethodInsn(INVOKEVIRTUAL, this.classDesc, this.method.getName(), this.methodDescr, false); + } + } } diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java index c223ab257037..57cc2168ea15 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java @@ -21,21 +21,28 @@ import java.lang.invoke.MethodType; import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.Date; import java.util.GregorianCalendar; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; +import example.Color; +import example.FruitMap; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.expression.EvaluationContext; import org.springframework.expression.Expression; import org.springframework.expression.ExpressionParser; +import org.springframework.expression.IndexAccessor; import org.springframework.expression.Operation; import org.springframework.expression.OperatorOverloader; import org.springframework.expression.common.TemplateParserContext; import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.ReflectiveIndexAccessor; import org.springframework.expression.spel.support.SimpleEvaluationContext; import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.expression.spel.testresources.Inventor; @@ -161,60 +168,117 @@ void literals() { class PropertiesArraysListsMapsAndIndexers { @Test - void propertyAccess() { + void propertyNavigation() { EvaluationContext context = TestScenarioCreator.getTestEvaluationContext(); + + // evaluates to 1856 int year = (Integer) parser.parseExpression("Birthdate.Year + 1900").getValue(context); // 1856 assertThat(year).isEqualTo(1856); + // evaluates to "Smiljan" String city = (String) parser.parseExpression("placeOfBirth.City").getValue(context); assertThat(city).isEqualTo("Smiljan"); } @Test - void propertyNavigation() { + void indexingIntoArraysAndCollections() { ExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext teslaContext = TestScenarioCreator.getTestEvaluationContext(); + StandardEvaluationContext societyContext = new StandardEvaluationContext(); + societyContext.setRootObject(new IEEE()); // Inventions Array + // evaluates to "Induction motor" String invention = parser.parseExpression("inventions[3]").getValue(teslaContext, String.class); assertThat(invention).isEqualTo("Induction motor"); // Members List - StandardEvaluationContext societyContext = new StandardEvaluationContext(); - societyContext.setRootObject(new IEEE()); // evaluates to "Nikola Tesla" String name = parser.parseExpression("members[0].Name").getValue(societyContext, String.class); assertThat(name).isEqualTo("Nikola Tesla"); - // List and Array navigation + // List and Array Indexing + // evaluates to "Wireless communication" invention = parser.parseExpression("members[0].Inventions[6]").getValue(societyContext, String.class); assertThat(invention).isEqualTo("Wireless communication"); } @Test - void maps() { + void indexingIntoStrings() { + ExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext societyContext = new StandardEvaluationContext(); societyContext.setRootObject(new IEEE()); - // Officer's map - Inventor pupin = parser.parseExpression("officers['president']").getValue(societyContext, Inventor.class); + + // evaluates to "T" (8th letter of "Nikola Tesla") + String character = parser.parseExpression("members[0].name[7]") + .getValue(societyContext, String.class); + assertThat(character).isEqualTo("T"); + } + + @Test + void indexingIntoMaps() { + StandardEvaluationContext societyContext = new StandardEvaluationContext(); + societyContext.setRootObject(new IEEE()); + + // Officer's Map + + // evaluates to Inventor("Pupin") + Inventor pupin = parser.parseExpression("officers['president']") + .getValue(societyContext, Inventor.class); assertThat(pupin).isNotNull(); + assertThat(pupin.getName()).isEqualTo("Pupin"); // evaluates to "Idvor" - String city = parser.parseExpression("officers['president'].PlaceOfBirth.city").getValue(societyContext, String.class); - assertThat(city).isNotNull(); + String city = parser.parseExpression("officers['president'].placeOfBirth.city") + .getValue(societyContext, String.class); + assertThat(city).isEqualTo("Idvor"); + + String countryExpression = "officers['advisors'][0].placeOfBirth.Country"; // setting values - Inventor i = parser.parseExpression("officers['advisors'][0]").getValue(societyContext, Inventor.class); - assertThat(i.getName()).isEqualTo("Nikola Tesla"); + parser.parseExpression(countryExpression) + .setValue(societyContext, "Croatia"); + + // evaluates to "Croatia" + String country = parser.parseExpression(countryExpression) + .getValue(societyContext, String.class); + assertThat(country).isEqualTo("Croatia"); + } + + @Test + void indexingIntoObjects() { + ExpressionParser parser = new SpelExpressionParser(); + + // Create an inventor to use as the root context object. + Inventor tesla = new Inventor("Nikola Tesla"); + + // evaluates to "Nikola Tesla" + String name = parser.parseExpression("#root['name']") + .getValue(context, tesla, String.class); + assertThat(name).isEqualTo("Nikola Tesla"); + } + + @Test + void indexingIntoCustomStructure() { + // Create a ReflectiveIndexAccessor for FruitMap + IndexAccessor fruitMapAccessor = new ReflectiveIndexAccessor( + FruitMap.class, Color.class, "getFruit", "setFruit"); + + // Register the IndexAccessor for FruitMap + context.addIndexAccessor(fruitMapAccessor); - parser.parseExpression("officers['advisors'][0].PlaceOfBirth.Country").setValue(societyContext, "Croatia"); + // Register the fruitMap variable + context.setVariable("fruitMap", new FruitMap()); - Inventor i2 = parser.parseExpression("reverse[0]['advisors'][0]").getValue(societyContext, Inventor.class); - assertThat(i2.getName()).isEqualTo("Nikola Tesla"); + // evaluates to "cherry" + String fruit = parser.parseExpression("#fruitMap[T(example.Color).RED]") + .getValue(context, String.class); + assertThat(fruit).isEqualTo("cherry"); } + } @Nested @@ -597,7 +661,8 @@ void registerFunctionViaMethodHandleFullyBound() throws Exception { MethodHandle methodHandle = MethodHandles.lookup().findVirtual(String.class, "formatted", MethodType.methodType(String.class, Object[].class)) .bindTo(template) - .bindTo(varargs); // here we have to provide arguments in a single array binding + // Here we have to provide the arguments in a single array binding: + .bindTo(varargs); context.registerFunction("message", methodHandle); String message = parser.parseExpression("#message()").getValue(context, String.class); @@ -605,6 +670,59 @@ void registerFunctionViaMethodHandleFullyBound() throws Exception { } } + @Nested + class Varargs { + + @Test + void varargsMethodInvocationWithIndividualArguments() { + // evaluates to "blue is color #1" + String expression = "'%s is color #%d'.formatted('blue', 1)"; + String message = parser.parseExpression(expression) + .getValue(String.class); + assertThat(message).isEqualTo("blue is color #1"); + } + + @Test + void varargsMethodInvocationWithArgumentsAsObjectArray() { + // evaluates to "blue is color #1" + String expression = "'%s is color #%d'.formatted(new Object[] {'blue', 1})"; + String message = parser.parseExpression(expression) + .getValue(String.class); + assertThat(message).isEqualTo("blue is color #1"); + } + + @Test + void varargsMethodInvocationWithArgumentsAsInlineList() { + // evaluates to "blue is color #1" + String expression = "'%s is color #%d'.formatted({'blue', 1})"; + String message = parser.parseExpression(expression).getValue(String.class); + assertThat(message).isEqualTo("blue is color #1"); + } + + @Test + void varargsMethodInvocationWithTypeConversion() { + Method reverseStringsMethod = ReflectionUtils.findMethod(StringUtils.class, "reverseStrings", String[].class); + SimpleEvaluationContext evaluationContext = SimpleEvaluationContext.forReadOnlyDataBinding().build(); + evaluationContext.setVariable("reverseStrings", reverseStringsMethod); + + // String reverseStrings(String... strings) + // evaluates to "3.0, 2.0, 1.0, SpEL" + String expression = "#reverseStrings('SpEL', 1, 10F / 5, 3.0000)"; + String message = parser.parseExpression(expression) + .getValue(evaluationContext, String.class); + assertThat(message).isEqualTo("3.0, 2.0, 1, SpEL"); + } + + @Test + void varargsMethodInvocationWithArgumentsAsStringArray() { + // evaluates to "blue is color #1" + String expression = "'%s is color #%s'.formatted(new String[] {'blue', 1})"; + String message = parser.parseExpression(expression).getValue(String.class); + assertThat(message).isEqualTo("blue is color #1"); + } + + } + @Nested class TernaryOperator { @@ -649,6 +767,24 @@ void nullSafePropertyAccess() { assertThat(city).isNull(); } + @Test + void nullSafeIndexing() { + IEEE society = new IEEE(); + EvaluationContext context = new StandardEvaluationContext(society); + + // evaluates to Inventor("Nikola Tesla") + Inventor inventor = parser.parseExpression("members?.[0]") // <1> + .getValue(context, Inventor.class); + assertThat(inventor).extracting(Inventor::getName).isEqualTo("Nikola Tesla"); + + society.members = null; + + // evaluates to null - does not throw an Exception + inventor = parser.parseExpression("members?.[0]") // <2> + .getValue(context, Inventor.class); + assertThat(inventor).isNull(); + } + @Test @SuppressWarnings("unchecked") void nullSafeSelection() { @@ -826,6 +962,12 @@ static class StringUtils { public static String reverseString(String input) { return new StringBuilder(input).reverse().toString(); } + + public static String reverseStrings(String... strings) { + List list = Arrays.asList(strings); + Collections.reverse(list); + return list.stream().collect(Collectors.joining(", ")); + } } private static class ListConcatenation implements OperatorOverloader { diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/SpelReproTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/SpelReproTests.java index f8491daae4e1..5e7d4987da53 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/SpelReproTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/SpelReproTests.java @@ -1326,7 +1326,7 @@ void SPR9495() { assertThat(Array.get(result, 2)).isEqualTo(XYZ.Z); } - @Test + @Test // https://github.com/spring-projects/spring-framework/issues/15119 void SPR10486() { SpelExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext context = new StandardEvaluationContext(); diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/TestScenarioCreator.java b/spring-expression/src/test/java/org/springframework/expression/spel/TestScenarioCreator.java index 895952f62a45..a99d3f5aea3f 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/TestScenarioCreator.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/TestScenarioCreator.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -108,11 +108,21 @@ private static void populateMethodHandles(StandardEvaluationContext testContext) "formatObjectVarargs", MethodType.methodType(String.class, String.class, Object[].class)); testContext.registerFunction("formatObjectVarargs", formatObjectVarargs); - // #formatObjectVarargs(format, args...) + // #formatPrimitiveVarargs(format, args...) MethodHandle formatPrimitiveVarargs = MethodHandles.lookup().findStatic(TestScenarioCreator.class, "formatPrimitiveVarargs", MethodType.methodType(String.class, String.class, int[].class)); testContext.registerFunction("formatPrimitiveVarargs", formatPrimitiveVarargs); + // #varargsFunctionHandle(args...) + MethodHandle varargsFunctionHandle = MethodHandles.lookup().findStatic(TestScenarioCreator.class, + "varargsFunction", MethodType.methodType(String.class, String[].class)); + testContext.registerFunction("varargsFunctionHandle", varargsFunctionHandle); + + // #varargsObjectFunctionHandle(args...) + MethodHandle varargsObjectFunctionHandle = MethodHandles.lookup().findStatic(TestScenarioCreator.class, + "varargsObjectFunction", MethodType.methodType(String.class, Object[].class)); + testContext.registerFunction("varargsObjectFunctionHandle", varargsObjectFunctionHandle); + // #add(int, int) MethodHandle add = MethodHandles.lookup().findStatic(TestScenarioCreator.class, "add", MethodType.methodType(int.class, int.class, int.class)); diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/VariableAndFunctionTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/VariableAndFunctionTests.java index 29a4255254a1..4e5e22df0c59 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/VariableAndFunctionTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/VariableAndFunctionTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,11 +16,21 @@ package org.springframework.expression.spel; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpression; import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.expression.spel.support.StandardTypeConverter; import org.springframework.expression.spel.support.StandardTypeLocator; +import org.springframework.lang.Nullable; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -32,6 +42,8 @@ * * @author Andy Clement * @author Sam Brannen + * @see ConstructorInvocationTests + * @see MethodInvocationTests */ class VariableAndFunctionTests extends AbstractExpressionTests { @@ -77,6 +89,8 @@ void functionInvocationWithStringArgument() { @Test void functionWithVarargs() { + // static String varargsFunction(String... strings) -> Arrays.toString(strings) + evaluate("#varargsFunction()", "[]", String.class); evaluate("#varargsFunction(new String[0])", "[]", String.class); evaluate("#varargsFunction('a')", "[a]", String.class); @@ -239,13 +253,33 @@ void functionFromMethodHandleWithListConvertedToVarargsArray() { evaluate("#formatObjectVarargs('x -> %s %s %s', {'a', 'b', 'c'})", expected, String.class); } + @Test // gh-34109 + void functionViaMethodHandleForStaticMethodThatAcceptsOnlyVarargs() { + // #varargsFunctionHandle: static String varargsFunction(String... strings) -> Arrays.toString(strings) + + evaluate("#varargsFunctionHandle()", "[]", String.class); + evaluate("#varargsFunctionHandle(new String[0])", "[]", String.class); + evaluate("#varargsFunctionHandle('a')", "[a]", String.class); + evaluate("#varargsFunctionHandle('a','b','c')", "[a, b, c]", String.class); + evaluate("#varargsFunctionHandle(new String[]{'a','b','c'})", "[a, b, c]", String.class); + // Conversion from int to String + evaluate("#varargsFunctionHandle(25)", "[25]", String.class); + evaluate("#varargsFunctionHandle('b',25)", "[b, 25]", String.class); + evaluate("#varargsFunctionHandle(new int[]{1, 2, 3})", "[1, 2, 3]", String.class); + // Strings that contain a comma + evaluate("#varargsFunctionHandle('a,b')", "[a,b]", String.class); + evaluate("#varargsFunctionHandle('a', 'x,y', 'd')", "[a, x,y, d]", String.class); + // null values + evaluate("#varargsFunctionHandle(null)", "[null]", String.class); + evaluate("#varargsFunctionHandle('a',null,'b')", "[a, null, b]", String.class); + } + @Test void functionMethodMustBeStatic() throws Exception { - SpelExpressionParser parser = new SpelExpressionParser(); - StandardEvaluationContext ctx = new StandardEvaluationContext(); - ctx.setVariable("notStatic", this.getClass().getMethod("nonStatic")); + context.registerFunction("nonStatic", this.getClass().getMethod("nonStatic")); + SpelExpression expression = parser.parseRaw("#nonStatic()"); assertThatExceptionOfType(SpelEvaluationException.class) - .isThrownBy(() -> parser.parseRaw("#notStatic()").getValue(ctx)) + .isThrownBy(() -> expression.getValue(context)) .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(FUNCTION_MUST_BE_STATIC)); } @@ -254,4 +288,75 @@ void functionMethodMustBeStatic() throws Exception { public void nonStatic() { } + + @Nested // gh-34371 + class VarargsAndPojoToArrayConversionTests { + + private final StandardEvaluationContext context = TestScenarioCreator.getTestEvaluationContext(); + + private final ArrayHolder arrayHolder = new ArrayHolder("a", "b", "c"); + + + @BeforeEach + void setUp() { + DefaultConversionService conversionService = new DefaultConversionService(); + conversionService.addConverter(new ArrayHolderConverter()); + context.setTypeConverter(new StandardTypeConverter(conversionService)); + context.setVariable("arrayHolder", arrayHolder); + } + + @Test + void functionWithVarargsAndPojoToArrayConversion() { + // #varargsFunction: static String varargsFunction(String... strings) -> Arrays.toString(strings) + evaluate("#varargsFunction(#arrayHolder)", "[a, b, c]"); + + // #varargsObjectFunction: static String varargsObjectFunction(Object... args) -> Arrays.toString(args) + // + // Since ArrayHolder is an "instanceof Object" and Object is the varargs component type, + // we expect the ArrayHolder not to be converted to an array but rather to be passed + // "as is" as a single argument to the varargs method. + evaluate("#varargsObjectFunction(#arrayHolder)", "[" + arrayHolder.toString() + "]"); + } + + @Test + void functionWithVarargsAndPojoToArrayConversionViaMethodHandle() { + // #varargsFunctionHandle: static String varargsFunction(String... strings) -> Arrays.toString(strings) + evaluate("#varargsFunctionHandle(#arrayHolder)", "[a, b, c]"); + + // #varargsObjectFunctionHandle: static String varargsObjectFunction(Object... args) -> Arrays.toString(args) + // + // Since ArrayHolder is an "instanceof Object" and Object is the varargs component type, + // we expect the ArrayHolder not to be converted to an array but rather to be passed + // "as is" as a single argument to the varargs method. + evaluate("#varargsObjectFunctionHandle(#arrayHolder)", "[" + arrayHolder.toString() + "]"); + } + + private void evaluate(String expression, Object expectedValue) { + Expression expr = parser.parseExpression(expression); + assertThat(expr).as("expression").isNotNull(); + Object value = expr.getValue(context); + assertThat(value).as("expression '" + expression + "'").isEqualTo(expectedValue); + } + + + record ArrayHolder(String... array) { + } + + static class ArrayHolderConverter implements GenericConverter { + + @Nullable + @Override + public Set getConvertibleTypes() { + return Set.of(new ConvertiblePair(ArrayHolder.class, Object[].class)); + } + + @Nullable + @Override + public String[] convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + return ((ArrayHolder) source).array(); + } + } + + } + } diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/ast/AccessorUtilsTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/ast/AccessorUtilsTests.java new file mode 100644 index 000000000000..659a99a87adf --- /dev/null +++ b/spring-expression/src/test/java/org/springframework/expression/spel/ast/AccessorUtilsTests.java @@ -0,0 +1,125 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.expression.spel.ast; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.expression.TargetedAccessor; +import org.springframework.lang.Nullable; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AccessorUtils}. + * + * @author Sam Brannen + * @since 6.1.15 + */ +class AccessorUtilsTests { + + private final TargetedAccessor animal1Accessor = createAccessor("Animal1", Animal.class); + + private final TargetedAccessor animal2Accessor = createAccessor("Animal2", Animal.class); + + private final TargetedAccessor cat1Accessor = createAccessor("Cat1", Cat.class); + + private final TargetedAccessor cat2Accessor = createAccessor("Cat2", Cat.class); + + private final TargetedAccessor generic1Accessor = createAccessor("Generic1", null); + + private final TargetedAccessor generic2Accessor = createAccessor("Generic2", null); + + private final List accessors = List.of( + generic1Accessor, + cat1Accessor, + animal1Accessor, + animal2Accessor, + cat2Accessor, + generic2Accessor + ); + + + @Test + void emptyAccessorsList() { + List accessorsToTry = AccessorUtils.getAccessorsToTry(new Cat(), List.of()); + assertThat(accessorsToTry).isEmpty(); + } + + @Test + void noMatch() { + List accessorsToTry = AccessorUtils.getAccessorsToTry(new Dog(), List.of(cat1Accessor)); + assertThat(accessorsToTry).isEmpty(); + } + + @Test + void singleExactTypeMatch() { + List accessorsToTry = AccessorUtils.getAccessorsToTry(new Cat(), List.of(cat1Accessor)); + assertThat(accessorsToTry).containsExactly(cat1Accessor); + } + + @Test + void exactTypeSupertypeAndGenericMatches() { + List accessorsToTry = AccessorUtils.getAccessorsToTry(new Cat(), accessors); + assertThat(accessorsToTry).containsExactly( + cat1Accessor, cat2Accessor, animal1Accessor, animal2Accessor, generic1Accessor, generic2Accessor); + } + + @Test + void supertypeAndGenericMatches() { + List accessorsToTry = AccessorUtils.getAccessorsToTry(new Dog(), accessors); + assertThat(accessorsToTry).containsExactly( + animal1Accessor, animal2Accessor, generic1Accessor, generic2Accessor); + } + + @Test + void genericMatches() { + List accessorsToTry = AccessorUtils.getAccessorsToTry("not an Animal", accessors); + assertThat(accessorsToTry).containsExactly(generic1Accessor, generic2Accessor); + } + + + private static TargetedAccessor createAccessor(String name, Class type) { + return new DemoAccessor(name, (type != null ? new Class[] { type } : null)); + } + + + private record DemoAccessor(String name, Class[] types) implements TargetedAccessor { + + @Override + @Nullable + public Class[] getSpecificTargetClasses() { + return this.types; + } + + @Override + public final String toString() { + return this.name; + } + } + + sealed interface Animal permits Cat, Dog { + } + + static final class Cat implements Animal { + } + + static final class Dog implements Animal { + } + +} diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/ast/AstUtilsTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/ast/AstUtilsTests.java deleted file mode 100644 index 174fdff9041e..000000000000 --- a/spring-expression/src/test/java/org/springframework/expression/spel/ast/AstUtilsTests.java +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed under the Apache License, Version 2.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.expression.spel.ast; - -import java.util.List; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -import org.springframework.expression.EvaluationContext; -import org.springframework.expression.PropertyAccessor; -import org.springframework.expression.TypedValue; -import org.springframework.lang.Nullable; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link AstUtils}. - * - * @author Sam Brannen - * @since 6.1.15 - */ -class AstUtilsTests { - - private final PropertyAccessor animal1Accessor = createAccessor("Animal1", Animal.class); - - private final PropertyAccessor animal2Accessor = createAccessor("Animal2", Animal.class); - - private final PropertyAccessor cat1Accessor = createAccessor("Cat1", Cat.class); - - private final PropertyAccessor cat2Accessor = createAccessor("Cat2", Cat.class); - - private final PropertyAccessor generic1Accessor = createAccessor("Generic1", null); - - private final PropertyAccessor generic2Accessor = createAccessor("Generic2", null); - - private final List accessors = List.of( - generic1Accessor, - cat1Accessor, - animal1Accessor, - animal2Accessor, - cat2Accessor, - generic2Accessor - ); - - - @Test - void emptyAccessorsList() { - List accessorsToTry = getPropertyAccessorsToTry(new Cat(), List.of()); - assertThat(accessorsToTry).isEmpty(); - } - - @Test - void noMatch() { - List accessorsToTry = getPropertyAccessorsToTry(new Dog(), List.of(cat1Accessor)); - assertThat(accessorsToTry).isEmpty(); - } - - @Test - void singleExactTypeMatch() { - List accessorsToTry = getPropertyAccessorsToTry(new Cat(), List.of(cat1Accessor)); - assertThat(accessorsToTry).containsExactly(cat1Accessor); - } - - @Test - void exactTypeMatches() { - List accessorsToTry = getPropertyAccessorsToTry(new Cat(), accessors); - // We would actually expect the following. - // assertThat(accessorsToTry).containsExactly( - // cat1Accessor, cat2Accessor, animal1Accessor, animal2Accessor, generic1Accessor, generic2Accessor); - // However, prior to Spring Framework 6.2, the supertype and generic accessors are not - // ordered properly. So we test that the exact matches come first and in the expected order. - assertThat(accessorsToTry) - .hasSize(accessors.size()) - .startsWith(cat1Accessor, cat2Accessor); - } - - @Disabled("PropertyAccessor ordering for supertype and generic matches is broken prior to Spring Framework 6.2") - @Test - void supertypeMatches() { - List accessorsToTry = getPropertyAccessorsToTry(new Dog(), accessors); - assertThat(accessorsToTry).containsExactly( - animal1Accessor, animal2Accessor, generic1Accessor, generic2Accessor); - } - - @Test - void genericMatches() { - List accessorsToTry = getPropertyAccessorsToTry("not an Animal", accessors); - assertThat(accessorsToTry).containsExactly(generic1Accessor, generic2Accessor); - } - - - private static PropertyAccessor createAccessor(String name, Class type) { - return new DemoAccessor(name, type); - } - - private static List getPropertyAccessorsToTry(Object target, List propertyAccessors) { - return AstUtils.getPropertyAccessorsToTry(target.getClass(), propertyAccessors); - } - - - private static class DemoAccessor implements PropertyAccessor { - - private final String name; - private final Class[] types; - - DemoAccessor(String name, Class type) { - this.name = name; - this.types = (type != null ? new Class[] {type} : null); - } - - @Override - @Nullable - public Class[] getSpecificTargetClasses() { - return this.types; - } - - @Override - public String toString() { - return this.name; - } - - @Override - public boolean canRead(EvaluationContext context, Object target, String name) { - return true; - } - - @Override - public TypedValue read(EvaluationContext context, Object target, String name) { - throw new UnsupportedOperationException("Auto-generated method stub"); - } - - @Override - public boolean canWrite(EvaluationContext context, Object target, String name) { - return false; - } - - @Override - public void write(EvaluationContext context, Object target, String name, Object newValue) { - /* no-op */ - } - } - - sealed interface Animal permits Bat, Cat, Dog { - } - - static final class Bat implements Animal { - } - - static final class Cat implements Animal { - } - - static final class Dog implements Animal { - } - -} diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/ast/InlineCollectionTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/ast/InlineCollectionTests.java index 453b98ee6406..cbe310d4ad14 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/ast/InlineCollectionTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/ast/InlineCollectionTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,14 +88,14 @@ void listWithPropertyAccessIsNotCached() { @Test void listCanBeCompiled() { SpelExpression listExpression = parseExpression("{1, -2, 3, 4}"); - assertThat(((SpelNodeImpl) listExpression.getAST()).isCompilable()).isTrue(); + assertThat(listExpression.getAST().isCompilable()).isTrue(); assertThat(SpelCompiler.compile(listExpression)).isTrue(); } @Test void dynamicListCannotBeCompiled() { SpelExpression listExpression = parseExpression("{1, (5 - 3), 3, 4}"); - assertThat(((SpelNodeImpl) listExpression.getAST()).isCompilable()).isFalse(); + assertThat(listExpression.getAST().isCompilable()).isFalse(); assertThat(SpelCompiler.compile(listExpression)).isFalse(); } diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/ast/OpPlusTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/ast/OpPlusTests.java index 2e274e270081..46bc9f7a97d6 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/ast/OpPlusTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/ast/OpPlusTests.java @@ -35,113 +35,107 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; /** - * Tests for SpEL's plus operator. + * Tests for SpEL's {@link OpPlus} operator. * * @author Ivo Smid * @author Chris Beams + * @author Sam Brannen * @since 3.2 - * @see OpPlus */ class OpPlusTests { + private final ExpressionState expressionState = new ExpressionState(new StandardEvaluationContext()); + + @Test - void test_emptyOperands() { - assertThatIllegalArgumentException().isThrownBy(() -> - new OpPlus(-1, -1)); + void emptyOperands() { + assertThatIllegalArgumentException().isThrownBy(() -> new OpPlus(-1, -1)); } @Test - void test_unaryPlusWithStringLiteral() { - ExpressionState expressionState = new ExpressionState(new StandardEvaluationContext()); + void unaryPlusWithStringLiteral() { + StringLiteral stringLiteral = new StringLiteral("word", -1, -1, "word"); + + OpPlus operator = new OpPlus(-1, -1, stringLiteral); + assertThatExceptionOfType(SpelEvaluationException.class) + .isThrownBy(() -> operator.getValueInternal(expressionState)); + } + + @Test + void unaryPlusWithIntegerOperand() { + IntLiteral intLiteral = new IntLiteral("123", -1, -1, 123); + OpPlus operator = new OpPlus(-1, -1, intLiteral); + TypedValue value = operator.getValueInternal(expressionState); + + assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(Integer.class); + assertThat(value.getTypeDescriptor().getType()).isEqualTo(Integer.class); + assertThat(value.getValue()).isEqualTo(intLiteral.getLiteralValue().getValue()); + } - StringLiteral str = new StringLiteral("word", -1, -1, "word"); + @Test + void unaryPlusWithLongOperand() { + LongLiteral longLiteral = new LongLiteral("123", -1, -1, 123L); + OpPlus operator = new OpPlus(-1, -1, longLiteral); + TypedValue value = operator.getValueInternal(expressionState); + + assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(Long.class); + assertThat(value.getTypeDescriptor().getType()).isEqualTo(Long.class); + assertThat(value.getValue()).isEqualTo(longLiteral.getLiteralValue().getValue()); + } - OpPlus o = new OpPlus(-1, -1, str); - assertThatExceptionOfType(SpelEvaluationException.class).isThrownBy(() -> - o.getValueInternal(expressionState)); + @Test + void unaryPlusWithRealOperand() { + RealLiteral realLiteral = new RealLiteral("123.00", -1, -1, 123.0); + OpPlus operator = new OpPlus(-1, -1, realLiteral); + TypedValue value = operator.getValueInternal(expressionState); + + assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(Double.class); + assertThat(value.getTypeDescriptor().getType()).isEqualTo(Double.class); + assertThat(value.getValue()).isEqualTo(realLiteral.getLiteralValue().getValue()); } @Test - void test_unaryPlusWithNumberOperand() { - ExpressionState expressionState = new ExpressionState(new StandardEvaluationContext()); - - { - RealLiteral realLiteral = new RealLiteral("123.00", -1, -1, 123.0); - OpPlus o = new OpPlus(-1, -1, realLiteral); - TypedValue value = o.getValueInternal(expressionState); - - assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(Double.class); - assertThat(value.getTypeDescriptor().getType()).isEqualTo(Double.class); - assertThat(value.getValue()).isEqualTo(realLiteral.getLiteralValue().getValue()); - } - - { - IntLiteral intLiteral = new IntLiteral("123", -1, -1, 123); - OpPlus o = new OpPlus(-1, -1, intLiteral); - TypedValue value = o.getValueInternal(expressionState); - - assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(Integer.class); - assertThat(value.getTypeDescriptor().getType()).isEqualTo(Integer.class); - assertThat(value.getValue()).isEqualTo(intLiteral.getLiteralValue().getValue()); - } - - { - LongLiteral longLiteral = new LongLiteral("123", -1, -1, 123L); - OpPlus o = new OpPlus(-1, -1, longLiteral); - TypedValue value = o.getValueInternal(expressionState); - - assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(Long.class); - assertThat(value.getTypeDescriptor().getType()).isEqualTo(Long.class); - assertThat(value.getValue()).isEqualTo(longLiteral.getLiteralValue().getValue()); - } + void binaryPlusWithIntegerOperands() { + IntLiteral n1 = new IntLiteral("123", -1, -1, 123); + IntLiteral n2 = new IntLiteral("456", -1, -1, 456); + OpPlus operator = new OpPlus(-1, -1, n1, n2); + TypedValue value = operator.getValueInternal(expressionState); + + assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(Integer.class); + assertThat(value.getTypeDescriptor().getType()).isEqualTo(Integer.class); + assertThat(value.getValue()).isEqualTo(123 + 456); } @Test - void test_binaryPlusWithNumberOperands() { - ExpressionState expressionState = new ExpressionState(new StandardEvaluationContext()); - - { - RealLiteral n1 = new RealLiteral("123.00", -1, -1, 123.0); - RealLiteral n2 = new RealLiteral("456.00", -1, -1, 456.0); - OpPlus o = new OpPlus(-1, -1, n1, n2); - TypedValue value = o.getValueInternal(expressionState); - - assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(Double.class); - assertThat(value.getTypeDescriptor().getType()).isEqualTo(Double.class); - assertThat(value.getValue()).isEqualTo(123.0 + 456.0); - } - - { - LongLiteral n1 = new LongLiteral("123", -1, -1, 123L); - LongLiteral n2 = new LongLiteral("456", -1, -1, 456L); - OpPlus o = new OpPlus(-1, -1, n1, n2); - TypedValue value = o.getValueInternal(expressionState); - - assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(Long.class); - assertThat(value.getTypeDescriptor().getType()).isEqualTo(Long.class); - assertThat(value.getValue()).isEqualTo(123L + 456L); - } - - { - IntLiteral n1 = new IntLiteral("123", -1, -1, 123); - IntLiteral n2 = new IntLiteral("456", -1, -1, 456); - OpPlus o = new OpPlus(-1, -1, n1, n2); - TypedValue value = o.getValueInternal(expressionState); - - assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(Integer.class); - assertThat(value.getTypeDescriptor().getType()).isEqualTo(Integer.class); - assertThat(value.getValue()).isEqualTo(123 + 456); - } + void binaryPlusWithLongOperands() { + LongLiteral n1 = new LongLiteral("123", -1, -1, 123L); + LongLiteral n2 = new LongLiteral("456", -1, -1, 456L); + OpPlus operator = new OpPlus(-1, -1, n1, n2); + TypedValue value = operator.getValueInternal(expressionState); + + assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(Long.class); + assertThat(value.getTypeDescriptor().getType()).isEqualTo(Long.class); + assertThat(value.getValue()).isEqualTo(123L + 456L); } @Test - void test_binaryPlusWithStringOperands() { - ExpressionState expressionState = new ExpressionState(new StandardEvaluationContext()); + void binaryPlusWithRealOperands() { + RealLiteral n1 = new RealLiteral("123.00", -1, -1, 123.0); + RealLiteral n2 = new RealLiteral("456.00", -1, -1, 456.0); + OpPlus operator = new OpPlus(-1, -1, n1, n2); + TypedValue value = operator.getValueInternal(expressionState); + + assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(Double.class); + assertThat(value.getTypeDescriptor().getType()).isEqualTo(Double.class); + assertThat(value.getValue()).isEqualTo(123.0 + 456.0); + } - StringLiteral n1 = new StringLiteral("\"foo\"", -1, -1, "\"foo\""); - StringLiteral n2 = new StringLiteral("\"bar\"", -1, -1, "\"bar\""); - OpPlus o = new OpPlus(-1, -1, n1, n2); - TypedValue value = o.getValueInternal(expressionState); + @Test + void binaryPlusWithStringOperands() { + StringLiteral str1 = new StringLiteral("\"foo\"", -1, -1, "\"foo\""); + StringLiteral str2 = new StringLiteral("\"bar\"", -1, -1, "\"bar\""); + OpPlus operator = new OpPlus(-1, -1, str1, str2); + TypedValue value = operator.getValueInternal(expressionState); assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(String.class); assertThat(value.getTypeDescriptor().getType()).isEqualTo(String.class); @@ -149,13 +143,11 @@ void test_binaryPlusWithStringOperands() { } @Test - void test_binaryPlusWithLeftStringOperand() { - ExpressionState expressionState = new ExpressionState(new StandardEvaluationContext()); - - StringLiteral n1 = new StringLiteral("\"number is \"", -1, -1, "\"number is \""); - LongLiteral n2 = new LongLiteral("123", -1, -1, 123); - OpPlus o = new OpPlus(-1, -1, n1, n2); - TypedValue value = o.getValueInternal(expressionState); + void binaryPlusWithLeftStringOperand() { + StringLiteral stringLiteral = new StringLiteral("\"number is \"", -1, -1, "\"number is \""); + LongLiteral longLiteral = new LongLiteral("123", -1, -1, 123); + OpPlus operator = new OpPlus(-1, -1, stringLiteral, longLiteral); + TypedValue value = operator.getValueInternal(expressionState); assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(String.class); assertThat(value.getTypeDescriptor().getType()).isEqualTo(String.class); @@ -163,13 +155,11 @@ void test_binaryPlusWithLeftStringOperand() { } @Test - void test_binaryPlusWithRightStringOperand() { - ExpressionState expressionState = new ExpressionState(new StandardEvaluationContext()); - - LongLiteral n1 = new LongLiteral("123", -1, -1, 123); - StringLiteral n2 = new StringLiteral("\" is a number\"", -1, -1, "\" is a number\""); - OpPlus o = new OpPlus(-1, -1, n1, n2); - TypedValue value = o.getValueInternal(expressionState); + void binaryPlusWithRightStringOperand() { + LongLiteral longLiteral = new LongLiteral("123", -1, -1, 123); + StringLiteral stringLiteral = new StringLiteral("\" is a number\"", -1, -1, "\" is a number\""); + OpPlus operator = new OpPlus(-1, -1, longLiteral, stringLiteral); + TypedValue value = operator.getValueInternal(expressionState); assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(String.class); assertThat(value.getTypeDescriptor().getType()).isEqualTo(String.class); @@ -177,24 +167,23 @@ void test_binaryPlusWithRightStringOperand() { } @Test - void test_binaryPlusWithTime_ToString() { - ExpressionState expressionState = new ExpressionState(new StandardEvaluationContext()); + void binaryPlusWithSqlTimeToString() { Time time = new Time(new Date().getTime()); VariableReference var = new VariableReference("timeVar", -1, -1); var.setValue(expressionState, time); - StringLiteral n2 = new StringLiteral("\" is now\"", -1, -1, "\" is now\""); - OpPlus o = new OpPlus(-1, -1, var, n2); - TypedValue value = o.getValueInternal(expressionState); + StringLiteral stringLiteral = new StringLiteral("\" is now\"", -1, -1, "\" is now\""); + OpPlus operator = new OpPlus(-1, -1, var, stringLiteral); + TypedValue value = operator.getValueInternal(expressionState); assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(String.class); assertThat(value.getTypeDescriptor().getType()).isEqualTo(String.class); - assertThat(value.getValue()).isEqualTo((time + " is now")); + assertThat(value.getValue()).isEqualTo(time + " is now"); } @Test - void test_binaryPlusWithTimeConverted() { + void binaryPlusWithTimeConverted() { SimpleDateFormat format = new SimpleDateFormat("hh :--: mm :--: ss", Locale.ENGLISH); GenericConversionService conversionService = new GenericConversionService(); @@ -209,13 +198,13 @@ void test_binaryPlusWithTimeConverted() { VariableReference var = new VariableReference("timeVar", -1, -1); var.setValue(expressionState, time); - StringLiteral n2 = new StringLiteral("\" is now\"", -1, -1, "\" is now\""); - OpPlus o = new OpPlus(-1, -1, var, n2); - TypedValue value = o.getValueInternal(expressionState); + StringLiteral stringLiteral = new StringLiteral("\" is now\"", -1, -1, "\" is now\""); + OpPlus operator = new OpPlus(-1, -1, var, stringLiteral); + TypedValue value = operator.getValueInternal(expressionState); assertThat(value.getTypeDescriptor().getObjectType()).isEqualTo(String.class); assertThat(value.getTypeDescriptor().getType()).isEqualTo(String.class); - assertThat(value.getValue()).isEqualTo((format.format(time) + " is now")); + assertThat(value.getValue()).isEqualTo(format.format(time) + " is now"); } } diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/support/ReflectionHelperTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/support/ReflectionHelperTests.java index 8a2884e8f111..00b1ec3bc475 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/support/ReflectionHelperTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/support/ReflectionHelperTests.java @@ -450,22 +450,22 @@ void reflectiveMethodResolverForJdkProxies() throws Exception { * Used to validate the match returned from a compareArguments call. */ private void checkMatch(Class[] inputTypes, Class[] expectedTypes, StandardTypeConverter typeConverter, ArgumentsMatchKind expectedMatchKind) { - ReflectionHelper.ArgumentsMatchInfo matchInfo = ReflectionHelper.compareArguments(typeDescriptors(expectedTypes), typeDescriptors(inputTypes), typeConverter); + ArgumentsMatchKind matchKind = ReflectionHelper.compareArguments(typeDescriptors(expectedTypes), typeDescriptors(inputTypes), typeConverter); if (expectedMatchKind == null) { - assertThat(matchInfo).as("Did not expect them to match in any way").isNull(); + assertThat(matchKind).as("Did not expect them to match in any way").isNull(); } else { - assertThat(matchInfo).as("Should not be a null match").isNotNull(); + assertThat(matchKind).as("Should not be a null match").isNotNull(); } if (expectedMatchKind == EXACT) { - assertThat(matchInfo.isExactMatch()).isTrue(); + assertThat(matchKind.isExactMatch()).isTrue(); } else if (expectedMatchKind == CLOSE) { - assertThat(matchInfo.isCloseMatch()).isTrue(); + assertThat(matchKind.isCloseMatch()).isTrue(); } else if (expectedMatchKind == REQUIRES_CONVERSION) { - assertThat(matchInfo.isMatchRequiringConversion()).as("expected to be a match requiring conversion, but was " + matchInfo).isTrue(); + assertThat(matchKind.isMatchRequiringConversion()).as("expected to be a match requiring conversion, but was " + matchKind).isTrue(); } } @@ -475,18 +475,18 @@ else if (expectedMatchKind == REQUIRES_CONVERSION) { private static void checkMatchVarargs(Class[] inputTypes, Class[] expectedTypes, StandardTypeConverter typeConverter, ArgumentsMatchKind expectedMatchKind) { - ReflectionHelper.ArgumentsMatchInfo matchInfo = + ArgumentsMatchKind matchKind = ReflectionHelper.compareArgumentsVarargs(typeDescriptors(expectedTypes), typeDescriptors(inputTypes), typeConverter); if (expectedMatchKind == null) { - assertThat(matchInfo).as("Did not expect them to match in any way: " + matchInfo).isNull(); + assertThat(matchKind).as("Did not expect them to match in any way: " + matchKind).isNull(); } else { - assertThat(matchInfo).as("Should not be a null match").isNotNull(); + assertThat(matchKind).as("Should not be a null match").isNotNull(); switch (expectedMatchKind) { - case EXACT -> assertThat(matchInfo.isExactMatch()).isTrue(); - case CLOSE -> assertThat(matchInfo.isCloseMatch()).isTrue(); - case REQUIRES_CONVERSION -> assertThat(matchInfo.isMatchRequiringConversion()) - .as("expected to be a match requiring conversion, but was " + matchInfo).isTrue(); + case EXACT -> assertThat(matchKind.isExactMatch()).isTrue(); + case CLOSE -> assertThat(matchKind.isCloseMatch()).isTrue(); + case REQUIRES_CONVERSION -> assertThat(matchKind.isMatchRequiringConversion()) + .as("expected to be a match requiring conversion, but was " + matchKind).isTrue(); } } } diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/support/ReflectiveIndexAccessorTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/support/ReflectiveIndexAccessorTests.java new file mode 100644 index 000000000000..ec2102682704 --- /dev/null +++ b/spring-expression/src/test/java/org/springframework/expression/spel/support/ReflectiveIndexAccessorTests.java @@ -0,0 +1,129 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.expression.spel.support; + +import java.lang.reflect.Method; + +import example.Color; +import example.FruitMap; +import org.junit.jupiter.api.Test; + +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.TypedValue; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link ReflectiveIndexAccessor}. + * + * @author Sam Brannen + * @since 6.2 + * @see org.springframework.expression.spel.SpelCompilationCoverageTests + */ +class ReflectiveIndexAccessorTests { + + @Test + void nonexistentReadMethod() { + Class targetType = getClass(); + assertThatIllegalArgumentException() + .isThrownBy(() -> new ReflectiveIndexAccessor(targetType, int.class, "bogus")) + .withMessage("Failed to find public read-method 'bogus(int)' in class '%s'.", targetType.getCanonicalName()); + } + + @Test + void nonPublicReadMethod() { + Class targetType = PrivateReadMethod.class; + assertThatIllegalArgumentException() + .isThrownBy(() -> new ReflectiveIndexAccessor(targetType, int.class, "get")) + .withMessage("Failed to find public read-method 'get(int)' in class '%s'.", targetType.getCanonicalName()); + } + + @Test + void nonPublicWriteMethod() { + Class targetType = PrivateWriteMethod.class; + assertThatIllegalArgumentException() + .isThrownBy(() -> new ReflectiveIndexAccessor(targetType, int.class, "get", "set")) + .withMessage("Failed to find public write-method 'set(int, java.lang.Object)' in class '%s'.", + targetType.getCanonicalName()); + } + + @Test + void nonPublicDeclaringClass() { + Class targetType = NonPublicTargetType.class; + Method readMethod = ReflectionUtils.findMethod(targetType, "get", int.class); + ReflectiveIndexAccessor accessor = new ReflectiveIndexAccessor(targetType, int.class, "get"); + + assertThatIllegalStateException() + .isThrownBy(() -> accessor.generateCode(mock(), mock(), mock())) + .withMessage("Failed to find public declaring class for read-method: %s", readMethod); + } + + @Test + void publicReadAndWriteMethods() { + FruitMap fruitMap = new FruitMap(); + EvaluationContext context = mock(); + ReflectiveIndexAccessor accessor = + new ReflectiveIndexAccessor(FruitMap.class, Color.class, "getFruit", "setFruit"); + + assertThat(accessor.getSpecificTargetClasses()).containsOnly(FruitMap.class); + + assertThat(accessor.canRead(context, this, Color.RED)).isFalse(); + assertThat(accessor.canRead(context, fruitMap, this)).isFalse(); + assertThat(accessor.canRead(context, fruitMap, Color.RED)).isTrue(); + assertThat(accessor.read(context, fruitMap, Color.RED)).extracting(TypedValue::getValue).isEqualTo("cherry"); + + assertThat(accessor.canWrite(context, this, Color.RED)).isFalse(); + assertThat(accessor.canWrite(context, fruitMap, this)).isFalse(); + assertThat(accessor.canWrite(context, fruitMap, Color.RED)).isTrue(); + accessor.write(context, fruitMap, Color.RED, "strawberry"); + assertThat(fruitMap.getFruit(Color.RED)).isEqualTo("strawberry"); + assertThat(accessor.read(context, fruitMap, Color.RED)).extracting(TypedValue::getValue).isEqualTo("strawberry"); + + assertThat(accessor.isCompilable()).isTrue(); + assertThat(accessor.getIndexedValueType()).isEqualTo(String.class); + assertThatNoException().isThrownBy(() -> accessor.generateCode(mock(), mock(), mock())); + } + + + public static class PrivateReadMethod { + Object get(int i) { + return "foo"; + } + } + + public static class PrivateWriteMethod { + public Object get(int i) { + return "foo"; + } + + void set(int i, String value) { + // no-op + } + } + + static class NonPublicTargetType { + public Object get(int i) { + return "foo"; + } + } + +} diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/support/SimpleEvaluationContextTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/support/SimpleEvaluationContextTests.java index e2106ac25a35..1730e83c2a08 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/support/SimpleEvaluationContextTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/support/SimpleEvaluationContextTests.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test; import org.springframework.expression.Expression; +import org.springframework.expression.IndexAccessor; import org.springframework.expression.spel.CompilableMapAccessor; import org.springframework.expression.spel.SpelEvaluationException; import org.springframework.expression.spel.SpelMessage; @@ -45,6 +46,9 @@ */ class SimpleEvaluationContextTests { + private static final IndexAccessor colorsIndexAccessor = + new ReflectiveIndexAccessor(Colors.class, int.class, "get", "set"); + private final SpelExpressionParser parser = new SpelExpressionParser(); private final Model model = new Model(); @@ -52,14 +56,18 @@ class SimpleEvaluationContextTests { @Test void forReadWriteDataBinding() { - SimpleEvaluationContext context = SimpleEvaluationContext.forReadWriteDataBinding().build(); + SimpleEvaluationContext context = SimpleEvaluationContext.forReadWriteDataBinding() + .withIndexAccessors(colorsIndexAccessor) + .build(); assertReadWriteMode(context); } @Test void forReadOnlyDataBinding() { - SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); + SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding() + .withIndexAccessors(colorsIndexAccessor) + .build(); assertCommonReadOnlyModeBehavior(context); @@ -96,12 +104,16 @@ void forReadOnlyDataBinding() { // Object Index assertAssignmentDisabled(context, "['name'] = 'rejected'"); + + // Custom Index + assertAssignmentDisabled(context, "colors[4] = 'rejected'"); } @Test void forPropertyAccessorsInReadWriteMode() { SimpleEvaluationContext context = SimpleEvaluationContext .forPropertyAccessors(new CompilableMapAccessor(), DataBindingPropertyAccessor.forReadWriteAccess()) + .withIndexAccessors(colorsIndexAccessor) .build(); assertReadWriteMode(context); @@ -126,12 +138,13 @@ void forPropertyAccessorsInReadWriteMode() { @Test void forPropertyAccessorsInMixedReadOnlyMode() { SimpleEvaluationContext context = SimpleEvaluationContext - .forPropertyAccessors(new CompilableMapAccessor(), DataBindingPropertyAccessor.forReadOnlyAccess()) + .forPropertyAccessors(new CompilableMapAccessor(true), DataBindingPropertyAccessor.forReadOnlyAccess()) + .withIndexAccessors(colorsIndexAccessor) .build(); assertCommonReadOnlyModeBehavior(context); - // Map -- with key as property name supported by CompilableMapAccessor + // Map -- with key as property name supported by CompilableMapAccessor with allowWrite = true. Expression expression; expression = parser.parseExpression("map.yellow"); @@ -156,20 +169,24 @@ void forPropertyAccessorsInMixedReadOnlyMode() { .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.PROPERTY_OR_FIELD_NOT_WRITABLE)); // Array Index - parser.parseExpression("array[0]").setValue(context, model, "foo"); - assertThat(model.array).containsExactly("foo"); + expression = parser.parseExpression("array[0] = 'quux'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("quux"); + assertThat(model.array).containsExactly("quux"); // List Index - parser.parseExpression("list[0]").setValue(context, model, "cat"); - assertThat(model.list).containsExactly("cat"); + expression = parser.parseExpression("list[0] = 'elephant'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("elephant"); + assertThat(model.list).containsExactly("elephant"); // Map Index -- key as String - parser.parseExpression("map['red']").setValue(context, model, "cherry"); - assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "banana")); + expression = parser.parseExpression("map['red'] = 'strawberry'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("strawberry"); + assertThat(model.map).containsOnly(entry("red", "strawberry"), entry("yellow", "banana")); // Map Index -- key as pseudo property name - parser.parseExpression("map[yellow]").setValue(context, model, "lemon"); - assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "lemon")); + expression = parser.parseExpression("map[yellow] = 'star fruit'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("star fruit"); + assertThat(model.map).containsOnly(entry("red", "strawberry"), entry("yellow", "star fruit")); // String Index // The Indexer does not support writes when indexing into a String. @@ -178,10 +195,17 @@ void forPropertyAccessorsInMixedReadOnlyMode() { .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE)); // Object Index + // Although this goes through the Indexer, the PropertyAccessorValueRef actually uses + // registered PropertyAccessors to perform the write access, and that is disabled here. assertThatSpelEvaluationException() .isThrownBy(() -> parser.parseExpression("['name'] = 'rejected'").getValue(context, model)) .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE)); + // Custom Index + expression = parser.parseExpression("colors[5] = 'indigo'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("indigo"); + assertThat(model.colors.get(5)).isEqualTo("indigo"); + // WRITE -- via increment and decrement operators assertIncrementAndDecrementWritesForIndexedStructures(context); @@ -190,20 +214,13 @@ void forPropertyAccessorsInMixedReadOnlyMode() { @Test void forPropertyAccessorsWithAssignmentDisabled() { SimpleEvaluationContext context = SimpleEvaluationContext - .forPropertyAccessors(new CompilableMapAccessor(), DataBindingPropertyAccessor.forReadOnlyAccess()) + .forPropertyAccessors(new CompilableMapAccessor(false), DataBindingPropertyAccessor.forReadOnlyAccess()) + .withIndexAccessors(colorsIndexAccessor) .withAssignmentDisabled() .build(); assertCommonReadOnlyModeBehavior(context); - // Map -- with key as property name supported by CompilableMapAccessor - - Expression expression; - expression = parser.parseExpression("map.yellow"); - // setValue() is supported even though assignment is not. - expression.setValue(context, model, "pineapple"); - assertThat(expression.getValue(context, model, String.class)).isEqualTo("pineapple"); - // WRITE -- via assignment operator // Variable @@ -269,6 +286,10 @@ private void assertReadWriteMode(SimpleEvaluationContext context) { parser.parseExpression("map[yellow]").setValue(context, model, "lemon"); assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "lemon")); + // Custom Index + parser.parseExpression("colors[4]").setValue(context, model, "purple"); + assertThat(model.colors.get(4)).isEqualTo("purple"); + // READ assertReadAccess(context); @@ -323,6 +344,12 @@ private void assertReadWriteMode(SimpleEvaluationContext context) { expression = parser.parseExpression("['name']"); assertThat(expression.getValue(context, model, String.class)).isEqualTo("new name"); + // Custom Index + expression = parser.parseExpression("colors[5] = 'indigo'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("indigo"); + expression = parser.parseExpression("colors[5]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("indigo"); + // WRITE -- via increment and decrement operators assertIncrementAndDecrementWritesForProperties(context); @@ -362,6 +389,10 @@ private void assertCommonReadOnlyModeBehavior(SimpleEvaluationContext context) { parser.parseExpression("map[yellow]").setValue(context, model, "lemon"); assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "lemon")); + // Custom Index + parser.parseExpression("colors[4]").setValue(context, model, "purple"); + assertThat(model.colors.get(4)).isEqualTo("purple"); + // Since the setValue() attempts for "name" and "count" failed above, we have to set // them directly for assertReadAccess(). model.name = "test"; @@ -407,6 +438,10 @@ private void assertReadAccess(SimpleEvaluationContext context) { // Object Index expression = parser.parseExpression("['name']"); assertThat(expression.getValue(context, model, String.class)).isEqualTo("test"); + + // Custom Index + expression = parser.parseExpression("colors[4]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("purple"); } private void assertIncrementAndDecrementWritesForProperties(SimpleEvaluationContext context) { @@ -486,6 +521,7 @@ static class Model { private final int[] numbers = {99}; private final List list = new ArrayList<>(); private final Map map = new HashMap<>(); + private final Colors colors = new Colors(); Model() { this.list.add("replace me"); @@ -525,6 +561,32 @@ public Map getMap() { return this.map; } + public Colors getColors() { + return this.colors; + } + } + + static class Colors { + + private final Map map = new HashMap<>(); + + { + this.map.put(1, "red"); + this.map.put(2, "green"); + this.map.put(3, "blue"); + } + + public String get(int index) { + if (!this.map.containsKey(index)) { + throw new IndexOutOfBoundsException("No color for index " + index); + } + return this.map.get(index); + } + + public void set(int index, String color) { + this.map.put(index, color); + } + } } diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/testresources/Person.java b/spring-expression/src/test/java/org/springframework/expression/spel/testresources/Person.java index 17939f7a0f2d..3a04fdadd83a 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/testresources/Person.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/testresources/Person.java @@ -20,8 +20,15 @@ public class Person { private String privateName; + private int age; + Company company; + + public Person(int age) { + this.age = age; + } + public Person(String name) { this.privateName = name; } @@ -31,6 +38,7 @@ public Person(String name, Company company) { this.company = company; } + public String getName() { return privateName; } @@ -39,6 +47,14 @@ public void setName(String n) { this.privateName = n; } + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } + public Company getCompany() { return company; } diff --git a/spring-jcl/src/main/java/org/apache/commons/logging/LogAdapter.java b/spring-jcl/src/main/java/org/apache/commons/logging/LogAdapter.java index 021295d2dccf..c7074fdc0363 100644 --- a/spring-jcl/src/main/java/org/apache/commons/logging/LogAdapter.java +++ b/spring-jcl/src/main/java/org/apache/commons/logging/LogAdapter.java @@ -76,7 +76,7 @@ else if (slf4jApiPresent) { // java.logging module is not present by default on JDK 9. We are requiring // its presence if neither Log4j nor SLF4J is available; however, in the // case of Log4j or SLF4J, we are trying to prevent early initialization - // of the JavaUtilLog adapter - e.g. by a JVM in debug mode - when eagerly + // of the JavaUtilLog adapter - for example, by a JVM in debug mode - when eagerly // trying to parse the bytecode for all the cases of this switch clause. createLog = JavaUtilAdapter::createLog; } diff --git a/spring-jcl/src/main/java/org/apache/commons/logging/LogFactory.java b/spring-jcl/src/main/java/org/apache/commons/logging/LogFactory.java index 34426cc33f48..095cdbb9b629 100644 --- a/spring-jcl/src/main/java/org/apache/commons/logging/LogFactory.java +++ b/spring-jcl/src/main/java/org/apache/commons/logging/LogFactory.java @@ -38,7 +38,7 @@ *

    Note that this Commons Logging variant is only meant to be used for * infrastructure logging purposes in the core framework and in extensions. * It also serves as a common bridge for third-party libraries using the - * Commons Logging API, e.g. Apache HttpClient, and HtmlUnit, bringing + * Commons Logging API, for example, Apache HttpClient, and HtmlUnit, bringing * them into the same consistent arrangement without any extra bridge jars. * *

    For logging need in application code, prefer direct use of Log4j 2.x @@ -70,7 +70,7 @@ public static Log getLog(String name) { /** * This method only exists for compatibility with unusual Commons Logging API - * usage like e.g. {@code LogFactory.getFactory().getInstance(Class/String)}. + * usage like, for example, {@code LogFactory.getFactory().getInstance(Class/String)}. * @see #getInstance(Class) * @see #getInstance(String) * @deprecated in favor of {@link #getLog(Class)}/{@link #getLog(String)} diff --git a/spring-jcl/src/main/java/org/apache/commons/logging/impl/package-info.java b/spring-jcl/src/main/java/org/apache/commons/logging/impl/package-info.java index b06abd68edfe..fe9899ca2b33 100644 --- a/spring-jcl/src/main/java/org/apache/commons/logging/impl/package-info.java +++ b/spring-jcl/src/main/java/org/apache/commons/logging/impl/package-info.java @@ -4,7 +4,7 @@ * with special support for Log4J 2, SLF4J and {@code java.util.logging}. * *

    This {@code impl} package is only present for binary compatibility - * with existing Commons Logging usage, e.g. in Commons Configuration. + * with existing Commons Logging usage, for example, in Commons Configuration. * {@code NoOpLog} can be used as a {@code Log} fallback instance, and * {@code SimpleLog} is not meant to work (issuing a warning when used). */ diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/LobRetrievalFailureException.java b/spring-jdbc/src/main/java/org/springframework/jdbc/LobRetrievalFailureException.java index c53924a29226..1628da5c2d7f 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/LobRetrievalFailureException.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/LobRetrievalFailureException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +25,9 @@ * * @author Juergen Hoeller * @since 1.0.2 + * @deprecated as of 6.2 along with {@link org.springframework.jdbc.support.lob.LobHandler} */ +@Deprecated(since = "6.2") @SuppressWarnings("serial") public class LobRetrievalFailureException extends DataRetrievalFailureException { diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/AggregatedBatchUpdateException.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/AggregatedBatchUpdateException.java new file mode 100644 index 000000000000..671eacb2e1c0 --- /dev/null +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/AggregatedBatchUpdateException.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.jdbc.core; + +import java.sql.BatchUpdateException; + +/** + * A {@link BatchUpdateException} that provides additional information about + * batches that were successful prior to one failing. + * + * @author Stephane Nicoll + * @since 6.2 + */ +@SuppressWarnings("serial") +public class AggregatedBatchUpdateException extends BatchUpdateException { + + private final int[][] successfulUpdateCounts; + + private final BatchUpdateException originalException; + + /** + * Create an aggregated exception with the batches that have completed prior + * to the given {@code cause}. + * @param successfulUpdateCounts the counts of the batches that run successfully + * @param original the exception this instance aggregates + */ + public AggregatedBatchUpdateException(int[][] successfulUpdateCounts, BatchUpdateException original) { + super(original.getMessage(), original.getSQLState(), original.getErrorCode(), + original.getUpdateCounts(), original.getCause()); + this.successfulUpdateCounts = successfulUpdateCounts; + this.originalException = original; + // Copy state of the original exception + setNextException(original.getNextException()); + for (Throwable suppressed : original.getSuppressed()) { + addSuppressed(suppressed); + } + } + + /** + * Return the batches that have completed successfully, prior to this exception. + *

    Information about the batch that failed is available via + * {@link #getUpdateCounts()}. + * @return an array containing for each batch another array containing the numbers of + * rows affected by each update in the batch + * @see #getUpdateCounts() + */ + public int[][] getSuccessfulUpdateCounts() { + return this.successfulUpdateCounts; + } + + /** + * Return the original {@link BatchUpdateException} that this exception aggregates. + * @return the original exception + */ + public BatchUpdateException getOriginalException() { + return this.originalException; + } + +} diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/ArgumentTypePreparedStatementSetter.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/ArgumentTypePreparedStatementSetter.java index d254dc2c4237..28227f98e076 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/ArgumentTypePreparedStatementSetter.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/ArgumentTypePreparedStatementSetter.java @@ -46,6 +46,7 @@ public class ArgumentTypePreparedStatementSetter implements PreparedStatementSet * @param args the arguments to set * @param argTypes the corresponding SQL types of the arguments */ + @SuppressWarnings("NullAway") public ArgumentTypePreparedStatementSetter(@Nullable Object[] args, @Nullable int[] argTypes) { if ((args != null && argTypes == null) || (args == null && argTypes != null) || (args != null && args.length != argTypes.length)) { diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcOperations.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcOperations.java index 56f802155b9a..9824337edcf7 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcOperations.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcOperations.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +148,7 @@ public interface JdbcOperations { * @param sql the SQL query to execute * @param rowMapper a callback that will map one object per row * @return the result Stream, containing mapped objects, needing to be - * closed once fully processed (e.g. through a try-with-resources clause) + * closed once fully processed (for example, through a try-with-resources clause) * @throws DataAccessException if there is any problem executing the query * @since 5.3 * @see #queryForStream(String, RowMapper, Object...) @@ -255,9 +255,8 @@ public interface JdbcOperations { *

    The results will be mapped to an SqlRowSet which holds the data in a * disconnected fashion. This wrapper will translate any SQLExceptions thrown. *

    Note that, for the default implementation, JDBC RowSet support needs to - * be available at runtime: by default, Sun's {@code com.sun.rowset.CachedRowSetImpl} - * class is used, which is part of JDK 1.5+ and also available separately as part of - * Sun's JDBC RowSet Implementations download (rowset.jar). + * be available at runtime: by default, a standard JDBC {@code CachedRowSet} + * is used. * @param sql the SQL query to execute * @return an SqlRowSet representation (possibly a wrapper around a * {@code javax.sql.rowset.CachedRowSet}) @@ -555,7 +554,7 @@ List query(String sql, @Nullable PreparedStatementSetter pss, RowMapper List query(String sql, @Nullable PreparedStatementSetter pss, RowMapper Stream queryForStream(String sql, @Nullable PreparedStatementSetter pss, * may also contain {@link SqlParameterValue} objects which indicate not * only the argument value but also the SQL type and optionally the scale * @return the result Stream, containing mapped objects, needing to be - * closed once fully processed (e.g. through a try-with-resources clause) + * closed once fully processed (for example, through a try-with-resources clause) * @throws DataAccessException if the query fails * @since 5.3 */ @@ -874,9 +873,8 @@ List queryForList(String sql, Object[] args, int[] argTypes, Class ele *

    The results will be mapped to an SqlRowSet which holds the data in a * disconnected fashion. This wrapper will translate any SQLExceptions thrown. *

    Note that, for the default implementation, JDBC RowSet support needs to - * be available at runtime: by default, Sun's {@code com.sun.rowset.CachedRowSetImpl} - * class is used, which is part of JDK 1.5+ and also available separately as part of - * Sun's JDBC RowSet Implementations download (rowset.jar). + * be available at runtime: by default, a standard JDBC {@code CachedRowSet} + * is used. * @param sql the SQL query to execute * @param args arguments to bind to the query * @param argTypes the SQL types of the arguments @@ -897,9 +895,8 @@ List queryForList(String sql, Object[] args, int[] argTypes, Class ele *

    The results will be mapped to an SqlRowSet which holds the data in a * disconnected fashion. This wrapper will translate any SQLExceptions thrown. *

    Note that, for the default implementation, JDBC RowSet support needs to - * be available at runtime: by default, Sun's {@code com.sun.rowset.CachedRowSetImpl} - * class is used, which is part of JDK 1.5+ and also available separately as part of - * Sun's JDBC RowSet Implementations download (rowset.jar). + * be available at runtime: by default, a standard JDBC {@code CachedRowSet} + * is used. * @param sql the SQL query to execute * @param args arguments to bind to the query * (leaving it to the PreparedStatement to guess the corresponding SQL type); diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java index 699c15f0f049..9401a8feba7e 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java @@ -225,7 +225,7 @@ public boolean isIgnoreWarnings() { *

    Default is -1, indicating to use the JDBC driver's default configuration * (i.e. to not pass a specific fetch size setting on to the driver). *

    Note: As of 4.3, negative values other than -1 will get passed on to the - * driver, since e.g. MySQL supports special behavior for {@code Integer.MIN_VALUE}. + * driver, since, for example, MySQL supports special behavior for {@code Integer.MIN_VALUE}. * @see java.sql.Statement#setFetchSize */ public void setFetchSize(int fetchSize) { @@ -834,7 +834,7 @@ public List query(String sql, RowMapper rowMapper, @Nullable Object... * If this is {@code null}, the SQL will be assumed to contain no bind parameters. * @param rowMapper a callback that will map one object per row * @return the result Stream, containing mapped objects, needing to be - * closed once fully processed (e.g. through a try-with-resources clause) + * closed once fully processed (for example, through a try-with-resources clause) * @throws DataAccessException if the query fails * @since 5.3 */ @@ -907,11 +907,13 @@ public T queryForObject(String sql, Object[] args, int[] argTypes, Class @Deprecated @Override + @Nullable public T queryForObject(String sql, @Nullable Object[] args, Class requiredType) throws DataAccessException { return queryForObject(sql, args, getSingleColumnRowMapper(requiredType)); } @Override + @Nullable public T queryForObject(String sql, Class requiredType, @Nullable Object... args) throws DataAccessException { return queryForObject(sql, args, getSingleColumnRowMapper(requiredType)); } @@ -1114,7 +1116,13 @@ public int[][] batchUpdate(String sql, final Collection batchArgs, final int items = n - ((n % batchSize == 0) ? n / batchSize - 1 : (n / batchSize)) * batchSize; logger.trace("Sending SQL batch update #" + batchIdx + " with " + items + " items"); } - rowsAffected.add(ps.executeBatch()); + try { + int[] updateCounts = ps.executeBatch(); + rowsAffected.add(updateCounts); + } + catch (BatchUpdateException ex) { + throw new AggregatedBatchUpdateException(rowsAffected.toArray(int[][]::new), ex); + } } } else { diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/ParameterDisposer.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/ParameterDisposer.java index 56b669a9c46f..bc3094bf7ae6 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/ParameterDisposer.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/ParameterDisposer.java @@ -22,7 +22,7 @@ * *

    Typically implemented by {@code PreparedStatementCreators} and * {@code PreparedStatementSetters} that support {@link DisposableSqlTypeValue} - * objects (e.g. {@code SqlLobValue}) as parameters. + * objects (for example, {@code SqlLobValue}) as parameters. * * @author Thomas Risberg * @author Juergen Hoeller diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/SqlRowSetResultSetExtractor.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/SqlRowSetResultSetExtractor.java index b4c36b6773fb..d7cccb81bbfc 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/SqlRowSetResultSetExtractor.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/SqlRowSetResultSetExtractor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +79,7 @@ protected SqlRowSet createSqlRowSet(ResultSet rs) throws SQLException { /** * Create a new {@link CachedRowSet} instance, to be populated by * the {@code createSqlRowSet} implementation. - *

    The default implementation uses JDBC 4.1's {@link RowSetFactory}. + *

    The default implementation uses JDBC's {@link RowSetFactory}. * @return a new CachedRowSet instance * @throws SQLException if thrown by JDBC methods * @see #createSqlRowSet diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/StatementCreatorUtils.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/StatementCreatorUtils.java index 5bd9f4571cbf..3eea1df60613 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/StatementCreatorUtils.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/StatementCreatorUtils.java @@ -75,7 +75,7 @@ public abstract class StatementCreatorUtils { * {@link PreparedStatement#setNull} / {@link PreparedStatement#setObject} calls based on * well-known behavior of common databases. *

    Consider switching this flag to "true" if you experience misbehavior at runtime, - * e.g. with connection pool issues in case of an exception thrown from {@code getParameterType} + * for example, with connection pool issues in case of an exception thrown from {@code getParameterType} * (as reported on JBoss AS 7) or in case of performance problems (as reported on PostgreSQL). */ public static final String IGNORE_GETPARAMETERTYPE_PROPERTY_NAME = "spring.jdbc.getParameterType.ignore"; @@ -324,7 +324,7 @@ else if (typeName != null) { throw ex; } // Fall back to generic setNull call without SQL type specified - // (e.g. for MySQL TIME_WITH_TIMEZONE / TIMESTAMP_WITH_TIMEZONE). + // (for example, for MySQL TIME_WITH_TIMEZONE / TIMESTAMP_WITH_TIMEZONE). ps.setNull(paramIndex, Types.NULL); } } @@ -461,7 +461,7 @@ else if (inValue instanceof Calendar cal) { } catch (SQLFeatureNotSupportedException ex) { // Fall back to generic setObject call without SQL type specified - // (e.g. for MySQL TIME_WITH_TIMEZONE / TIMESTAMP_WITH_TIMEZONE). + // (for example, for MySQL TIME_WITH_TIMEZONE / TIMESTAMP_WITH_TIMEZONE). ps.setObject(paramIndex, inValue); } } diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/TableMetaDataContext.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/TableMetaDataContext.java index 5b655509783e..e7cb34de8fb9 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/TableMetaDataContext.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/TableMetaDataContext.java @@ -18,7 +18,6 @@ import java.util.ArrayList; import java.util.Collections; -import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -216,7 +215,7 @@ protected List reconcileColumnsToUse(List declaredColumns, Strin if (!declaredColumns.isEmpty()) { return new ArrayList<>(declaredColumns); } - Set keys = new LinkedHashSet<>(generatedKeyNames.length); + Set keys = CollectionUtils.newLinkedHashSet(generatedKeyNames.length); for (String key : generatedKeyNames) { keys.add(key.toUpperCase(Locale.ROOT)); } @@ -297,7 +296,7 @@ public List matchInParameterValuesWithInsertColumns(Map inPar * @return the insert string to be used */ public String createInsertString(String... generatedKeyNames) { - Set keys = new LinkedHashSet<>(generatedKeyNames.length); + Set keys = CollectionUtils.newLinkedHashSet(generatedKeyNames.length); for (String key : generatedKeyNames) { keys.add(key.toUpperCase(Locale.ROOT)); } diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/namedparam/NamedParameterJdbcOperations.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/namedparam/NamedParameterJdbcOperations.java index c42fc38f5481..f594c5e536f7 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/namedparam/NamedParameterJdbcOperations.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/namedparam/NamedParameterJdbcOperations.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -241,7 +241,7 @@ List query(String sql, Map paramMap, RowMapper rowMapper) * @param paramSource container of arguments to bind to the query * @param rowMapper object that will map one object per row * @return the result Stream, containing mapped objects, needing to be - * closed once fully processed (e.g. through a try-with-resources clause) + * closed once fully processed (for example, through a try-with-resources clause) * @throws DataAccessException if the query fails * @since 5.3 */ @@ -257,7 +257,7 @@ Stream queryForStream(String sql, SqlParameterSource paramSource, RowMapp * (leaving it to the PreparedStatement to guess the corresponding SQL type) * @param rowMapper object that will map one object per row * @return the result Stream, containing mapped objects, needing to be - * closed once fully processed (e.g. through a try-with-resources clause) + * closed once fully processed (for example, through a try-with-resources clause) * @throws DataAccessException if the query fails * @since 5.3 */ @@ -449,9 +449,8 @@ List queryForList(String sql, Map paramMap, Class elementTy *

    The results will be mapped to an SqlRowSet which holds the data in a * disconnected fashion. This wrapper will translate any SQLExceptions thrown. *

    Note that, for the default implementation, JDBC RowSet support needs to - * be available at runtime: by default, Sun's {@code com.sun.rowset.CachedRowSetImpl} - * class is used, which is part of JDK 1.5+ and also available separately as part of - * Sun's JDBC RowSet Implementations download (rowset.jar). + * be available at runtime: by default, a standard JDBC {@code CachedRowSet} + * is used. * @param sql the SQL query to execute * @param paramSource container of arguments to bind to the query * @return an SqlRowSet representation (possibly a wrapper around a @@ -469,9 +468,8 @@ List queryForList(String sql, Map paramMap, Class elementTy *

    The results will be mapped to an SqlRowSet which holds the data in a * disconnected fashion. This wrapper will translate any SQLExceptions thrown. *

    Note that, for the default implementation, JDBC RowSet support needs to - * be available at runtime: by default, Sun's {@code com.sun.rowset.CachedRowSetImpl} - * class is used, which is part of JDK 1.5+ and also available separately as part of - * Sun's JDBC RowSet Implementations download (rowset.jar). + * be available at runtime: by default, a standard JDBC {@code CachedRowSet} + * is used. * @param sql the SQL query to execute * @param paramMap map of parameters to bind to the query * (leaving it to the PreparedStatement to guess the corresponding SQL type) diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/namedparam/NamedParameterUtils.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/namedparam/NamedParameterUtils.java index f0c97dc573a4..4d9f1141dfa3 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/namedparam/NamedParameterUtils.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/namedparam/NamedParameterUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.util.ArrayList; import java.util.HashSet; -import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; @@ -42,14 +41,14 @@ public abstract class NamedParameterUtils { /** - * Set of characters that qualify as comment or quotes starting characters. + * Set of characters that qualify as comment or quote starting characters. */ - private static final String[] START_SKIP = new String[] {"'", "\"", "--", "/*"}; + private static final String[] START_SKIP = {"'", "\"", "--", "/*", "`"}; /** - * Set of characters that at are the corresponding comment or quotes ending characters. + * Set of characters that are the corresponding comment or quote ending characters. */ - private static final String[] STOP_SKIP = new String[] {"'", "\"", "\n", "*/"}; + private static final String[] STOP_SKIP = {"'", "\"", "\n", "*/", "`"}; /** * Set of characters that qualify as parameter separators, @@ -274,9 +273,10 @@ private static int skipCommentsAndQuotes(char[] statement, int position) { * parentheses. This allows for the use of "expression lists" in the SQL statement * like:

    * {@code select id, name, state from table where (name, age) in (('John', 35), ('Ann', 50))} - *

    The parameter values passed in are used to determine the number of placeholders to - * be used for a select list. Select lists should be limited to 100 or fewer elements. - * A larger number of elements is not guaranteed to be supported by the database and + *

    The parameter values passed in are used to determine the number of + * placeholders to be used for a select list. Select lists should not be empty + * and should be limited to 100 or fewer elements. An empty list or a larger + * number of elements is not guaranteed to be supported by the database and * is strictly vendor-dependent. * @param parsedSql the parsed representation of the SQL statement * @param paramSource the source for named parameters @@ -304,14 +304,12 @@ public static String substituteNamedParameters(ParsedSql parsedSql, @Nullable Sq value = sqlParameterValue.getValue(); } if (value instanceof Iterable iterable) { - Iterator entryIter = iterable.iterator(); int k = 0; - while (entryIter.hasNext()) { + for (Object entryItem : iterable) { if (k > 0) { actualSql.append(", "); } k++; - Object entryItem = entryIter.next(); if (entryItem instanceof Object[] expressionList) { actualSql.append('('); for (int m = 0; m < expressionList.length; m++) { @@ -463,7 +461,7 @@ public static List buildSqlParameterList(ParsedSql parsedSql, SqlP /** * Parse the SQL statement and locate any placeholders or named parameters. - * Named parameters are substituted for a JDBC placeholder. + *

    Named parameters are substituted for a JDBC placeholder. *

    This is a shortcut version of * {@link #parseSqlStatement(String)} in combination with * {@link #substituteNamedParameters(ParsedSql, SqlParameterSource)}. @@ -477,9 +475,10 @@ public static String parseSqlStatementIntoString(String sql) { /** * Parse the SQL statement and locate any placeholders or named parameters. - * Named parameters are substituted for a JDBC placeholder and any select list - * is expanded to the required number of placeholders. + *

    Named parameters are substituted for a JDBC placeholder, and any select + * list is expanded to the required number of placeholders. *

    This is a shortcut version of + * {@link #parseSqlStatement(String)} in combination with * {@link #substituteNamedParameters(ParsedSql, SqlParameterSource)}. * @param sql the SQL statement * @param paramSource the source for named parameters diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/AbstractJdbcCall.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/AbstractJdbcCall.java index d09c7e9e6c77..71d8589a1ee1 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/AbstractJdbcCall.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/AbstractJdbcCall.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author 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,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import javax.sql.DataSource; @@ -66,6 +68,9 @@ public abstract class AbstractJdbcCall { /** List of RefCursor/ResultSet RowMapper objects. */ private final Map> declaredRowMappers = new LinkedHashMap<>(); + /** Lock for the compilation step. */ + private final Lock compilationLock = new ReentrantLock(); + /** * Has this operation been compiled? Compilation means at least checking * that a DataSource or JdbcTemplate has been provided. @@ -284,24 +289,30 @@ public void addDeclaredRowMapper(String parameterName, RowMapper rowMapper) { * @throws org.springframework.dao.InvalidDataAccessApiUsageException if the object hasn't * been correctly initialized, for example if no DataSource has been provided */ - public final synchronized void compile() throws InvalidDataAccessApiUsageException { - if (!isCompiled()) { - if (getProcedureName() == null) { - throw new InvalidDataAccessApiUsageException("Procedure or Function name is required"); - } - try { - this.jdbcTemplate.afterPropertiesSet(); - } - catch (IllegalArgumentException ex) { - throw new InvalidDataAccessApiUsageException(ex.getMessage()); - } - compileInternal(); - this.compiled = true; - if (logger.isDebugEnabled()) { - logger.debug("SqlCall for " + (isFunction() ? "function" : "procedure") + - " [" + getProcedureName() + "] compiled"); + public final void compile() throws InvalidDataAccessApiUsageException { + this.compilationLock.lock(); + try { + if (!isCompiled()) { + if (getProcedureName() == null) { + throw new InvalidDataAccessApiUsageException("Procedure or Function name is required"); + } + try { + this.jdbcTemplate.afterPropertiesSet(); + } + catch (IllegalArgumentException ex) { + throw new InvalidDataAccessApiUsageException(ex.getMessage()); + } + compileInternal(); + this.compiled = true; + if (logger.isDebugEnabled()) { + logger.debug("SqlCall for " + (isFunction() ? "function" : "procedure") + + " [" + getProcedureName() + "] compiled"); + } } } + finally { + this.compilationLock.unlock(); + } } /** diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/JdbcClient.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/JdbcClient.java index 326938df90ee..5f80796b1aa8 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/JdbcClient.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/JdbcClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -206,7 +206,7 @@ interface StatementSpec { *

    The given parameter object will define all named parameters * based on its JavaBean properties, record components, or raw fields. * A Map instance can be provided as a complete parameter source as well. - * @param namedParamObject a custom parameter object (e.g. a JavaBean, + * @param namedParamObject a custom parameter object (for example, a JavaBean, * record class, or field holder) with named properties serving as * statement parameters * @return this statement specification (for chaining) @@ -343,12 +343,26 @@ interface ResultQuerySpec { /** * Retrieve a single value result. - * @return the single row represented as its single column value + *

    Note: As of 6.2, this will enforce non-null result values + * as originally designed (just accidentally not enforced before). + * (never {@code null}) + * @see #optionalValue() * @see DataAccessUtils#requiredSingleResult(Collection) */ default Object singleValue() { return DataAccessUtils.requiredSingleResult(singleColumn()); } + + /** + * Retrieve a single value result, if available, as an {@link Optional} handle. + * @return an Optional handle with the single column value from the single row + * @since 6.2 + * @see #singleValue() + * @see DataAccessUtils#optionalResult(Collection) + */ + default Optional optionalValue() { + return DataAccessUtils.optionalResult(singleColumn()); + } } @@ -363,7 +377,7 @@ interface MappedQuerySpec { * Retrieve the result as a lazily resolved stream of mapped objects, * retaining the order from the original database result. * @return the result Stream, containing mapped objects, needing to be - * closed once fully processed (e.g. through a try-with-resources clause) + * closed once fully processed (for example, through a try-with-resources clause) */ Stream stream(); @@ -384,25 +398,27 @@ default Set set() { return new LinkedHashSet<>(list()); } - /** - * Retrieve a single result, if available, as an {@link Optional} handle. - * @return an Optional handle with a single result object or none - * @see #list() - * @see DataAccessUtils#optionalResult(Collection) - */ - default Optional optional() { - return DataAccessUtils.optionalResult(list()); - } - /** * Retrieve a single result as a required object instance. + *

    Note: As of 6.2, this will enforce non-null result values + * as originally designed (just accidentally not enforced before). * @return the single result object (never {@code null}) - * @see #list() + * @see #optional() * @see DataAccessUtils#requiredSingleResult(Collection) */ default T single() { return DataAccessUtils.requiredSingleResult(list()); } + + /** + * Retrieve a single result, if available, as an {@link Optional} handle. + * @return an Optional handle with a single result object or none + * @see #single() + * @see DataAccessUtils#optionalResult(Collection) + */ + default Optional optional() { + return DataAccessUtils.optionalResult(list()); + } } } diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/SimpleJdbcCall.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/SimpleJdbcCall.java index c5d946fad431..6331423200b4 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/SimpleJdbcCall.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/SimpleJdbcCall.java @@ -26,6 +26,7 @@ import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.SqlParameter; import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.lang.Nullable; /** * A SimpleJdbcCall is a multithreaded, reusable object representing a call @@ -148,36 +149,42 @@ public SimpleJdbcCall withNamedBinding() { } @Override + @Nullable @SuppressWarnings("unchecked") public T executeFunction(Class returnType, Object... args) { return (T) doExecute(args).get(getScalarOutParameterName()); } @Override + @Nullable @SuppressWarnings("unchecked") public T executeFunction(Class returnType, Map args) { return (T) doExecute(args).get(getScalarOutParameterName()); } @Override + @Nullable @SuppressWarnings("unchecked") public T executeFunction(Class returnType, SqlParameterSource args) { return (T) doExecute(args).get(getScalarOutParameterName()); } @Override + @Nullable @SuppressWarnings("unchecked") public T executeObject(Class returnType, Object... args) { return (T) doExecute(args).get(getScalarOutParameterName()); } @Override + @Nullable @SuppressWarnings("unchecked") public T executeObject(Class returnType, Map args) { return (T) doExecute(args).get(getScalarOutParameterName()); } @Override + @Nullable @SuppressWarnings("unchecked") public T executeObject(Class returnType, SqlParameterSource args) { return (T) doExecute(args).get(getScalarOutParameterName()); diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/SimpleJdbcCallOperations.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/SimpleJdbcCallOperations.java index 5b33f8d62bd6..91db9847925d 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/SimpleJdbcCallOperations.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/SimpleJdbcCallOperations.java @@ -21,6 +21,7 @@ import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.SqlParameter; import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.lang.Nullable; /** * Interface specifying the API for a Simple JDBC Call implemented by {@link SimpleJdbcCall}. @@ -117,6 +118,7 @@ public interface SimpleJdbcCallOperations { * Parameter values must be provided in the same order as the parameters are defined * for the stored procedure. */ + @Nullable T executeFunction(Class returnType, Object... args); /** @@ -125,6 +127,7 @@ public interface SimpleJdbcCallOperations { * @param returnType the type of the value to return * @param args a Map containing the parameter values to be used in the call */ + @Nullable T executeFunction(Class returnType, Map args); /** @@ -133,6 +136,7 @@ public interface SimpleJdbcCallOperations { * @param returnType the type of the value to return * @param args the MapSqlParameterSource containing the parameter values to be used in the call */ + @Nullable T executeFunction(Class returnType, SqlParameterSource args); /** @@ -144,6 +148,7 @@ public interface SimpleJdbcCallOperations { * Parameter values must be provided in the same order as the parameters are defined for * the stored procedure. */ + @Nullable T executeObject(Class returnType, Object... args); /** @@ -153,6 +158,7 @@ public interface SimpleJdbcCallOperations { * @param returnType the type of the value to return * @param args a Map containing the parameter values to be used in the call */ + @Nullable T executeObject(Class returnType, Map args); /** @@ -162,6 +168,7 @@ public interface SimpleJdbcCallOperations { * @param returnType the type of the value to return * @param args the MapSqlParameterSource containing the parameter values to be used in the call */ + @Nullable T executeObject(Class returnType, SqlParameterSource args); /** diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/AbstractLobCreatingPreparedStatementCallback.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/AbstractLobCreatingPreparedStatementCallback.java index 99135870c6c9..eb32870d3d98 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/AbstractLobCreatingPreparedStatementCallback.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/AbstractLobCreatingPreparedStatementCallback.java @@ -51,7 +51,9 @@ * @author Juergen Hoeller * @since 1.0.2 * @see org.springframework.jdbc.support.lob.LobCreator + * @deprecated as of 6.2, in favor of {@link SqlBinaryValue} and {@link SqlCharacterValue} */ +@Deprecated(since = "6.2") public abstract class AbstractLobCreatingPreparedStatementCallback implements PreparedStatementCallback { private final LobHandler lobHandler; diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/AbstractLobStreamingResultSetExtractor.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/AbstractLobStreamingResultSetExtractor.java index 7b6de1078e88..c56e4a2811f9 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/AbstractLobStreamingResultSetExtractor.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/AbstractLobStreamingResultSetExtractor.java @@ -54,7 +54,10 @@ * @param the result type * @see org.springframework.jdbc.support.lob.LobHandler * @see org.springframework.jdbc.LobRetrievalFailureException + * @deprecated as of 6.2 along with {@link org.springframework.jdbc.support.lob.LobHandler}, + * in favor of {@link ResultSet#getBinaryStream}/{@link ResultSet#getCharacterStream} usage */ +@Deprecated(since = "6.2") public abstract class AbstractLobStreamingResultSetExtractor implements ResultSetExtractor { /** diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/JdbcBeanDefinitionReader.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/JdbcBeanDefinitionReader.java index d266c35ad7c0..9f932a5cfde0 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/JdbcBeanDefinitionReader.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/JdbcBeanDefinitionReader.java @@ -99,7 +99,7 @@ public void setJdbcTemplate(JdbcTemplate jdbcTemplate) { * Load bean definitions from the database via the given SQL string. * @param sql the SQL query to use for loading bean definitions. * The first three columns must be bean name, property name and value. - * Any join and any other columns are permitted: e.g. + * Any join and any other columns are permitted: for example, * {@code SELECT BEAN_NAME, PROPERTY, VALUE FROM CONFIG WHERE CONFIG.APP_ID = 1} * It's also possible to perform a join. Column names are not significant -- * only the ordering of these first three columns. diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlBinaryValue.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlBinaryValue.java index 4ac66702debd..08a9f3019c46 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlBinaryValue.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlBinaryValue.java @@ -29,14 +29,14 @@ import org.springframework.lang.Nullable; /** - * Object to represent a binary parameter value for a SQL statement, e.g. + * Object to represent a binary parameter value for a SQL statement, for example, * a binary stream for a BLOB or a LONGVARBINARY or PostgreSQL BYTEA column. * *

    Designed for use with {@link org.springframework.jdbc.core.JdbcTemplate} * as well as {@link org.springframework.jdbc.core.simple.JdbcClient}, to be * passed in as a parameter value wrapping the target content value. Can be * combined with {@link org.springframework.jdbc.core.SqlParameterValue} for - * specifying a SQL type, e.g. + * specifying a SQL type, for example, * {@code new SqlParameterValue(Types.BLOB, new SqlBinaryValue(myContent))}. * With most database drivers, the type hint is not actually necessary. * diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlCharacterValue.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlCharacterValue.java index 655e60b30086..aaaf80d79835 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlCharacterValue.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlCharacterValue.java @@ -29,13 +29,13 @@ /** * Object to represent a character-based parameter value for a SQL statement, - * e.g. a character stream for a CLOB/NCLOB or a LONGVARCHAR column. + * for example, a character stream for a CLOB/NCLOB or a LONGVARCHAR column. * *

    Designed for use with {@link org.springframework.jdbc.core.JdbcTemplate} * as well as {@link org.springframework.jdbc.core.simple.JdbcClient}, to be * passed in as a parameter value wrapping the target content value. Can be * combined with {@link org.springframework.jdbc.core.SqlParameterValue} for - * specifying a SQL type, e.g. + * specifying a SQL type, for example, * {@code new SqlParameterValue(Types.CLOB, new SqlCharacterValue(myContent))}. * With most database drivers, the type hint is not actually necessary. * diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlLobValue.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlLobValue.java index 192dd580073e..c208be0e3f40 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlLobValue.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlLobValue.java @@ -68,7 +68,9 @@ * @see org.springframework.jdbc.core.JdbcTemplate#update(String, Object[], int[]) * @see org.springframework.jdbc.object.SqlUpdate#update(Object[]) * @see org.springframework.jdbc.object.StoredProcedure#execute(java.util.Map) + * @deprecated as of 6.2, in favor of {@link SqlBinaryValue} and {@link SqlCharacterValue} */ +@Deprecated(since = "6.2") public class SqlLobValue implements DisposableSqlTypeValue { @Nullable diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceTransactionManager.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceTransactionManager.java index f67d848b085b..49d111d3d6bf 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceTransactionManager.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceTransactionManager.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -91,7 +91,7 @@ *

    This transaction manager can be used as a replacement for the * {@link org.springframework.transaction.jta.JtaTransactionManager} in the single * resource case, as it does not require a container that supports JTA, typically - * in combination with a locally defined JDBC {@code DataSource} (e.g. a Hikari + * in combination with a locally defined JDBC {@code DataSource} (for example, a Hikari * connection pool). Switching between this local strategy and a JTA environment * is just a matter of configuration! * @@ -99,7 +99,7 @@ * transaction synchronizations (if synchronization is generally active), assuming * resources operating on the underlying JDBC {@code Connection}. This allows for * setup analogous to {@code JtaTransactionManager}, in particular with respect to - * lazily registered ORM resources (e.g. a Hibernate {@code Session}). + * lazily registered ORM resources (for example, a Hibernate {@code Session}). * *

    NOTE: As of 5.3, {@link org.springframework.jdbc.support.JdbcTransactionManager} * is available as an extended subclass which includes commit/rollback exception @@ -211,7 +211,7 @@ protected DataSource obtainDataSource() { * this read-only mode provides read consistency for the entire transaction. *

    Note that older Oracle JDBC drivers (9i, 10g) used to enforce this read-only * mode even for {@code Connection.setReadOnly(true}. However, with recent drivers, - * this strong enforcement needs to be applied explicitly, e.g. through this flag. + * this strong enforcement needs to be applied explicitly, for example, through this flag. * @since 4.3.7 * @see #prepareTransactionalConnection */ @@ -478,9 +478,7 @@ public boolean isRollbackOnly() { @Override public void flush() { - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationUtils.triggerFlush(); - } + TransactionSynchronizationUtils.triggerFlush(); } } diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java index 2787b30d902f..ad99f2e2d55d 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java @@ -35,7 +35,7 @@ /** * Helper class that provides static methods for obtaining JDBC {@code Connection}s * from a {@link javax.sql.DataSource}. Includes special support for Spring-managed - * transactional {@code Connection}s, e.g. managed by {@link DataSourceTransactionManager} + * transactional {@code Connection}s, for example, managed by {@link DataSourceTransactionManager} * or {@link org.springframework.transaction.jta.JtaTransactionManager}. * *

    Used internally by Spring's {@link org.springframework.jdbc.core.JdbcTemplate}, @@ -67,7 +67,7 @@ public abstract class DataSourceUtils { * calling code and making any exception that is thrown more meaningful. *

    Is aware of a corresponding Connection bound to the current thread, for example * when using {@link DataSourceTransactionManager}. Will bind a Connection to the - * thread if transaction synchronization is active, e.g. when running within a + * thread if transaction synchronization is active, for example, when running within a * {@link org.springframework.transaction.jta.JtaTransactionManager JTA} transaction). * @param dataSource the DataSource to obtain Connections from * @return a JDBC Connection from the given DataSource @@ -93,7 +93,7 @@ public static Connection getConnection(DataSource dataSource) throws CannotGetJd * Same as {@link #getConnection}, but throwing the original SQLException. *

    Is aware of a corresponding Connection bound to the current thread, for example * when using {@link DataSourceTransactionManager}. Will bind a Connection to the thread - * if transaction synchronization is active (e.g. if in a JTA transaction). + * if transaction synchronization is active (for example, if in a JTA transaction). *

    Directly accessed by {@link TransactionAwareDataSourceProxy}. * @param dataSource the DataSource to obtain Connections from * @return a JDBC Connection from the given DataSource @@ -193,7 +193,7 @@ public static Integer prepareConnectionForTransaction(Connection con, @Nullable Throwable exToCheck = ex; while (exToCheck != null) { if (exToCheck.getClass().getSimpleName().contains("Timeout")) { - // Assume it's a connection timeout that would otherwise get lost: e.g. from JDBC 4.0 + // Assume it's a connection timeout that would otherwise get lost: for example, from JDBC 4.0 throw ex; } exToCheck = exToCheck.getCause(); @@ -465,7 +465,7 @@ private static int getConnectionSynchronizationOrder(DataSource dataSource) { /** * Callback for resource cleanup at the end of a non-native JDBC transaction - * (e.g. when participating in a JtaTransactionManager transaction). + * (for example, when participating in a JtaTransactionManager transaction). * @see org.springframework.transaction.jta.JtaTransactionManager */ private static class ConnectionSynchronization implements TransactionSynchronization { diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/IsolationLevelDataSourceAdapter.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/IsolationLevelDataSourceAdapter.java index 9b8a33d5ba1b..86a7b91de7ea 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/IsolationLevelDataSourceAdapter.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/IsolationLevelDataSourceAdapter.java @@ -37,7 +37,7 @@ * *

    Inherits the capability to apply specific user credentials from its superclass * {@link UserCredentialsDataSourceAdapter}; see the latter's javadoc for details - * on that functionality (e.g. {@link #setCredentialsForCurrentThread}). + * on that functionality (for example, {@link #setCredentialsForCurrentThread}). * *

    WARNING: This adapter simply calls * {@link java.sql.Connection#setTransactionIsolation} and/or diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/JdbcTransactionObjectSupport.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/JdbcTransactionObjectSupport.java index 61ff2c57232c..aaa09e3a691b 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/JdbcTransactionObjectSupport.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/JdbcTransactionObjectSupport.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author 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,11 +17,9 @@ package org.springframework.jdbc.datasource; import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; import java.sql.Savepoint; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - import org.springframework.lang.Nullable; import org.springframework.transaction.CannotCreateTransactionException; import org.springframework.transaction.NestedTransactionNotSupportedException; @@ -48,9 +46,6 @@ */ public abstract class JdbcTransactionObjectSupport implements SavepointManager, SmartTransactionObject { - private static final Log logger = LogFactory.getLog(JdbcTransactionObjectSupport.class); - - @Nullable private ConnectionHolder connectionHolder; @@ -185,8 +180,18 @@ public void releaseSavepoint(Object savepoint) throws TransactionException { try { conHolder.getConnection().releaseSavepoint((Savepoint) savepoint); } + catch (SQLFeatureNotSupportedException ex) { + // typically on Oracle - ignore + } + catch (SQLException ex) { + // ignore Microsoft SQLServerException: This operation is not supported. + String msg = ex.getMessage(); + if (msg == null || !msg.contains("not supported")) { + throw new TransactionSystemException("Could not explicitly release JDBC savepoint", ex); + } + } catch (Throwable ex) { - logger.debug("Could not explicitly release JDBC savepoint", ex); + throw new TransactionSystemException("Could not explicitly release JDBC savepoint", ex); } } diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java index 0fcb8b92e97b..38cd26a6272f 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java @@ -48,7 +48,7 @@ * without fetching a Connection from the pool or communicating with the * database; this will be done lazily on first creation of a JDBC Statement. * As a bonus, this allows for taking the transaction-synchronized read-only - * flag and/or isolation level into account in a routing DataSource (e.g. + * flag and/or isolation level into account in a routing DataSource (for example, * {@link org.springframework.jdbc.datasource.lookup.IsolationLevelDataSourceRouter}). * *

    If you configure both a LazyConnectionDataSourceProxy and a @@ -184,7 +184,7 @@ public void setDefaultTransactionIsolationName(String constantName) { /** * Set the default transaction isolation level to expose when no target Connection * has been fetched yet (when the actual JDBC Connection default is not known yet). - *

    This property accepts the int constant value (e.g. 8) as defined in the + *

    This property accepts the int constant value (for example, 8) as defined in the * {@link java.sql.Connection} interface; it is mainly intended for programmatic * use. Consider using the "defaultTransactionIsolationName" property for setting * the value by name (for example, {@code "TRANSACTION_SERIALIZABLE"}). diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/ShardingKeyProvider.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/ShardingKeyProvider.java index 98506832fe15..f9c99b177f7c 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/ShardingKeyProvider.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/ShardingKeyProvider.java @@ -27,7 +27,7 @@ * for providing the current sharding key (plus optionally a super sharding key) in * {@link org.springframework.jdbc.datasource.ShardingKeyDataSourceAdapter}. * - *

    Can be used as a functional interface (e.g. with a lambda expression) for a simple + *

    Can be used as a functional interface (for example, with a lambda expression) for a simple * sharding key, or as a two-method interface when including a super sharding key as well. * * @author Mohamed Lahyane (Anir) diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/SingleConnectionDataSource.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/SingleConnectionDataSource.java index c978e37aa042..a258aa3a21a0 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/SingleConnectionDataSource.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/SingleConnectionDataSource.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 @@ import java.lang.reflect.Proxy; import java.sql.Connection; import java.sql.SQLException; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import org.springframework.beans.factory.DisposableBean; import org.springframework.lang.Nullable; @@ -34,7 +36,7 @@ * *

    Note that at shutdown, someone should close the underlying Connection * via the {@code close()} method. Client code will never call close - * on the Connection handle if it is SmartDataSource-aware (e.g. uses + * on the Connection handle if it is SmartDataSource-aware (for example, uses * {@code DataSourceUtils.releaseConnection}). * *

    If client code will call {@code close()} in the assumption of a pooled @@ -73,8 +75,8 @@ public class SingleConnectionDataSource extends DriverManagerDataSource @Nullable private Connection connection; - /** Synchronization monitor for the shared Connection. */ - private final Object connectionMonitor = new Object(); + /** Lifecycle lock for the shared Connection. */ + private final Lock connectionLock = new ReentrantLock(); /** @@ -180,8 +182,10 @@ protected Boolean getAutoCommitValue() { @Override + @SuppressWarnings("NullAway") public Connection getConnection() throws SQLException { - synchronized (this.connectionMonitor) { + this.connectionLock.lock(); + try { if (this.connection == null) { // No underlying Connection -> lazy init via DriverManager. initConnection(); @@ -193,6 +197,9 @@ public Connection getConnection() throws SQLException { } return this.connection; } + finally { + this.connectionLock.unlock(); + } } /** @@ -216,9 +223,13 @@ public Connection getConnection(String username, String password) throws SQLExce */ @Override public boolean shouldClose(Connection con) { - synchronized (this.connectionMonitor) { + this.connectionLock.lock(); + try { return (con != this.connection && con != this.target); } + finally { + this.connectionLock.unlock(); + } } /** @@ -241,11 +252,15 @@ public void close() { */ @Override public void destroy() { - synchronized (this.connectionMonitor) { + this.connectionLock.lock(); + try { if (this.target != null) { closeConnection(this.target); } } + finally { + this.connectionLock.unlock(); + } } @@ -256,7 +271,8 @@ public void initConnection() throws SQLException { if (getUrl() == null) { throw new IllegalStateException("'url' property is required for lazily initializing a Connection"); } - synchronized (this.connectionMonitor) { + this.connectionLock.lock(); + try { if (this.target != null) { closeConnection(this.target); } @@ -267,19 +283,26 @@ public void initConnection() throws SQLException { } this.connection = (isSuppressClose() ? getCloseSuppressingConnectionProxy(this.target) : this.target); } + finally { + this.connectionLock.unlock(); + } } /** * Reset the underlying shared Connection, to be reinitialized on next access. */ public void resetConnection() { - synchronized (this.connectionMonitor) { + this.connectionLock.lock(); + try { if (this.target != null) { closeConnection(this.target); } this.target = null; this.connection = null; } + finally { + this.connectionLock.unlock(); + } } /** diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/TransactionAwareDataSourceProxy.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/TransactionAwareDataSourceProxy.java index 7f9a013c6210..d9cfce3885e8 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/TransactionAwareDataSourceProxy.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/TransactionAwareDataSourceProxy.java @@ -237,7 +237,7 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl if (this.target == null) { if (method.getName().equals("getWarnings") || method.getName().equals("clearWarnings")) { - // Avoid creation of target Connection on pre-close cleanup (e.g. Hibernate Session) + // Avoid creation of target Connection on pre-close cleanup (for example, Hibernate Session) return null; } if (this.closed) { diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseBuilder.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseBuilder.java index 9e021a6e34ae..ea5558d693d0 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseBuilder.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -112,7 +112,8 @@ public EmbeddedDatabaseBuilder setName(String databaseName) { } /** - * Set the type of embedded database. + * Set the type of embedded database. Consider using {@link #setDatabaseConfigurer} + * if customization of the connections properties is necessary. *

    Defaults to HSQL if not called. * @param databaseType the type of embedded database to build * @return {@code this}, to facilitate method chaining @@ -122,6 +123,19 @@ public EmbeddedDatabaseBuilder setType(EmbeddedDatabaseType databaseType) { return this; } + /** + * Set the {@linkplain EmbeddedDatabaseConfigurer configurer} to use to + * configure the embedded database, as an alternative to {@link #setType}. + * @param configurer the configurer of the embedded database + * @return {@code this}, to facilitate method chaining + * @since 6.2 + * @see EmbeddedDatabaseConfigurers + */ + public EmbeddedDatabaseBuilder setDatabaseConfigurer(EmbeddedDatabaseConfigurer configurer) { + this.databaseFactory.setDatabaseConfigurer(configurer); + return this; + } + /** * Set the factory to use to create the {@link DataSource} instance that * connects to the embedded database. diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseConfigurerDelegate.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseConfigurerDelegate.java new file mode 100644 index 000000000000..6c187a069212 --- /dev/null +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseConfigurerDelegate.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.jdbc.datasource.embedded; + +/** + * An {@link EmbeddedDatabaseConfigurer} delegate that can be used to customize + * the embedded database. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class EmbeddedDatabaseConfigurerDelegate extends AbstractEmbeddedDatabaseConfigurer { + + private final EmbeddedDatabaseConfigurer target; + + public EmbeddedDatabaseConfigurerDelegate(EmbeddedDatabaseConfigurer target) { + this.target = target; + } + + @Override + public void configureConnectionProperties(ConnectionProperties properties, String databaseName) { + this.target.configureConnectionProperties(properties, databaseName); + } + +} diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseConfigurerFactory.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseConfigurers.java similarity index 60% rename from spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseConfigurerFactory.java rename to spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseConfigurers.java index f9b728acd024..e748d34164bd 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseConfigurerFactory.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseConfigurers.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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.jdbc.datasource.embedded; +import java.util.function.UnaryOperator; + import org.springframework.util.Assert; /** @@ -25,28 +27,24 @@ * @author Keith Donald * @author Oliver Gierke * @author Sam Brannen - * @since 3.0 + * @author Stephane Nicoll + * @since 6.2 */ -final class EmbeddedDatabaseConfigurerFactory { - - private EmbeddedDatabaseConfigurerFactory() { - } - +public abstract class EmbeddedDatabaseConfigurers { /** * Return a configurer instance for the given embedded database type. - * @param type the embedded database type (HSQL, H2 or Derby) + * @param type the {@linkplain EmbeddedDatabaseType embedded database type} * @return the configurer instance * @throws IllegalStateException if the driver for the specified database type is not available */ - public static EmbeddedDatabaseConfigurer getConfigurer(EmbeddedDatabaseType type) throws IllegalStateException { + public static EmbeddedDatabaseConfigurer getConfigurer(EmbeddedDatabaseType type) { Assert.notNull(type, "EmbeddedDatabaseType is required"); try { return switch (type) { case HSQL -> HsqlEmbeddedDatabaseConfigurer.getInstance(); case H2 -> H2EmbeddedDatabaseConfigurer.getInstance(); case DERBY -> DerbyEmbeddedDatabaseConfigurer.getInstance(); - default -> throw new UnsupportedOperationException("Embedded database type [" + type + "] is not supported"); }; } catch (ClassNotFoundException | NoClassDefFoundError ex) { @@ -54,4 +52,20 @@ public static EmbeddedDatabaseConfigurer getConfigurer(EmbeddedDatabaseType type } } + /** + * Customize the default configurer for the given embedded database type. + *

    The {@code customizer} typically uses + * {@link EmbeddedDatabaseConfigurerDelegate} to customize things as necessary. + * @param type the {@linkplain EmbeddedDatabaseType embedded database type} + * @param customizer the customizer to return based on the default + * @return the customized configurer instance + * @throws IllegalStateException if the driver for the specified database type is not available + */ + public static EmbeddedDatabaseConfigurer customizeConfigurer( + EmbeddedDatabaseType type, UnaryOperator customizer) { + + EmbeddedDatabaseConfigurer defaultConfigurer = getConfigurer(type); + return customizer.apply(defaultConfigurer); + } + } diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseFactory.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseFactory.java index abdbfe26af32..0b08b5955ddb 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseFactory.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +45,11 @@ * for the database. *

  • Call {@link #setDatabaseName} to set an explicit name for the database. *
  • Call {@link #setDatabaseType} to set the database type if you wish to - * use one of the supported types. + * use one of the pre-supported types with its default settings. *
  • Call {@link #setDatabaseConfigurer} to configure support for a custom - * embedded database type. + * embedded database type, or + * {@linkplain EmbeddedDatabaseConfigurers#customizeConfigurer customize} the + * defaults for one of the pre-supported types. *
  • Call {@link #setDatabasePopulator} to change the algorithm used to * populate the database. *
  • Call {@link #setDataSourceFactory} to change the type of @@ -60,6 +62,7 @@ * @author Keith Donald * @author Juergen Hoeller * @author Sam Brannen + * @author Stephane Nicoll * @since 3.0 */ public class EmbeddedDatabaseFactory { @@ -124,17 +127,23 @@ public void setDataSourceFactory(DataSourceFactory dataSourceFactory) { /** * Set the type of embedded database to use. - *

    Call this when you wish to configure one of the pre-supported types. + *

    Call this when you wish to configure one of the pre-supported types + * with its default settings. *

    Defaults to HSQL. * @param type the database type */ public void setDatabaseType(EmbeddedDatabaseType type) { - this.databaseConfigurer = EmbeddedDatabaseConfigurerFactory.getConfigurer(type); + this.databaseConfigurer = EmbeddedDatabaseConfigurers.getConfigurer(type); } /** * Set the strategy that will be used to configure the embedded database instance. - *

    Call this when you wish to use an embedded database type not already supported. + *

    Call this with + * {@linkplain EmbeddedDatabaseConfigurers#customizeConfigurer customizeConfigurer} + * when you wish to customize the settings of one of the pre-supported types. + * Alternatively, use this when you wish to use an embedded database type not + * already supported. + * @since 6.2 */ public void setDatabaseConfigurer(EmbeddedDatabaseConfigurer configurer) { this.databaseConfigurer = configurer; @@ -153,6 +162,7 @@ public void setDatabasePopulator(DatabasePopulator populator) { * Factory method that returns the {@linkplain EmbeddedDatabase embedded database} * instance, which is also a {@link DataSource}. */ + @SuppressWarnings("NullAway") public EmbeddedDatabase getDatabase() { if (this.dataSource == null) { initDatabase(); @@ -178,7 +188,7 @@ protected void initDatabase() { // Create the embedded database first if (this.databaseConfigurer == null) { - this.databaseConfigurer = EmbeddedDatabaseConfigurerFactory.getConfigurer(EmbeddedDatabaseType.HSQL); + this.databaseConfigurer = EmbeddedDatabaseConfigurers.getConfigurer(EmbeddedDatabaseType.HSQL); } this.databaseConfigurer.configureConnectionProperties( this.dataSourceFactory.getConnectionProperties(), this.databaseName); diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/init/DatabasePopulatorUtils.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/init/DatabasePopulatorUtils.java index f3f0d5780f92..1dc788a33e50 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/init/DatabasePopulatorUtils.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/init/DatabasePopulatorUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author 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,10 +36,10 @@ public abstract class DatabasePopulatorUtils { /** * Execute the given {@link DatabasePopulator} against the given {@link DataSource}. - *

    As of Spring Framework 5.3.11, the {@link Connection} for the supplied - * {@code DataSource} will be {@linkplain Connection#commit() committed} if - * it is not configured for {@link Connection#getAutoCommit() auto-commit} and - * is not {@linkplain DataSourceUtils#isConnectionTransactional transactional}. + *

    The {@link Connection} for the supplied {@code DataSource} will be + * {@linkplain Connection#commit() committed} if it is not configured for + * {@link Connection#getAutoCommit() auto-commit} and is not + * {@linkplain DataSourceUtils#isConnectionTransactional transactional}. * @param populator the {@code DatabasePopulator} to execute * @param dataSource the {@code DataSource} to execute against * @throws DataAccessException if an error occurs, specifically a {@link ScriptException} diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/lookup/IsolationLevelDataSourceRouter.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/lookup/IsolationLevelDataSourceRouter.java index 5705c109e359..1ee290230ab7 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/lookup/IsolationLevelDataSourceRouter.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/lookup/IsolationLevelDataSourceRouter.java @@ -33,8 +33,8 @@ *

    This is particularly useful in combination with JTA transaction management * (typically through Spring's {@link org.springframework.transaction.jta.JtaTransactionManager}). * Standard JTA does not support transaction-specific isolation levels. Some JTA - * providers support isolation levels as a vendor-specific extension (e.g. WebLogic), - * which is the preferred way of addressing this. As an alternative (e.g. on WebSphere), + * providers support isolation levels as a vendor-specific extension (for example, WebLogic), + * which is the preferred way of addressing this. As an alternative (for example, on WebSphere), * the target database can be represented through multiple JNDI DataSources, each * configured with a different isolation level (for the entire DataSource). * {@code IsolationLevelDataSourceRouter} allows to transparently switch to the diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/JdbcTransactionManager.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/JdbcTransactionManager.java index e03987499a1d..6d630cc5f530 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/JdbcTransactionManager.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/JdbcTransactionManager.java @@ -31,7 +31,7 @@ * which applies the same {@link SQLExceptionTranslator} infrastructure by default. * *

    Exception translation is specifically relevant for commit steps in serializable - * transactions (e.g. on Postgres) where concurrency failures may occur late on commit. + * transactions (for example, on Postgres) where concurrency failures may occur late on commit. * This allows for throwing {@link org.springframework.dao.ConcurrencyFailureException} to * callers instead of {@link org.springframework.transaction.TransactionSystemException}. * diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/JdbcUtils.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/JdbcUtils.java index b12ddc0a63f9..84867940ef6c 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/JdbcUtils.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/JdbcUtils.java @@ -141,7 +141,7 @@ public static void closeResultSet(@Nullable ResultSet rs) { * {@link #getResultSetValue(java.sql.ResultSet, int)} for unknown types. *

    Note that the returned value may not be assignable to the specified * required type, in case of an unknown type. Calling code needs to deal - * with this case appropriately, e.g. throwing a corresponding exception. + * with this case appropriately, for example, throwing a corresponding exception. * @param rs is the ResultSet holding the data * @param index is the column index * @param requiredType the required value type (may be {@code null}) @@ -207,7 +207,7 @@ else if (Clob.class == requiredType) { } else if (requiredType.isEnum()) { // Enums can either be represented through a String or an enum index value: - // leave enum type conversion up to the caller (e.g. a ConversionService) + // leave enum type conversion up to the caller (for example, a ConversionService) // but make sure that we return nothing other than a String or an Integer. Object obj = rs.getObject(index); if (obj instanceof String) { @@ -219,7 +219,7 @@ else if (obj instanceof Number number) { return NumberUtils.convertNumberToTargetClass(number, Integer.class); } else { - // e.g. on Postgres: getObject returns a PGObject, but we need a String + // for example, on Postgres: getObject returns a PGObject, but we need a String return rs.getString(index); } } @@ -443,7 +443,7 @@ public static boolean supportsBatchUpdates(Connection con) { * Extract a common name for the target database in use even if * various drivers/platforms provide varying names at runtime. * @param source the name as provided in database meta-data - * @return the common name to be used (e.g. "DB2" or "Sybase") + * @return the common name to be used (for example, "DB2" or "Sybase") */ @Nullable public static String commonDatabaseName(@Nullable String source) { @@ -476,7 +476,7 @@ public static boolean isNumeric(int sqlType) { * Resolve the standard type name for the given SQL type, if possible. * @param sqlType the SQL type to resolve * @return the corresponding constant name in {@link java.sql.Types} - * (e.g. "VARCHAR"/"NUMERIC"), or {@code null} if not resolvable + * (for example, "VARCHAR"/"NUMERIC"), or {@code null} if not resolvable * @since 5.2 */ @Nullable diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLErrorCodeSQLExceptionTranslator.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLErrorCodeSQLExceptionTranslator.java index 16c8c092fbcf..b7e0af91d57c 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLErrorCodeSQLExceptionTranslator.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLErrorCodeSQLExceptionTranslator.java @@ -57,7 +57,7 @@ * *

    The configuration file named "sql-error-codes.xml" is by default read from * this package. It can be overridden through a file of the same name in the root - * of the class path (e.g. in the "/WEB-INF/classes" directory), as long as the + * of the class path (for example, in the "/WEB-INF/classes" directory), as long as the * Spring JDBC package is loaded from the same ClassLoader. * *

    This translator is commonly used by default if a user-provided `sql-error-codes.xml` @@ -217,7 +217,7 @@ protected DataAccessException doTranslate(String task, @Nullable String sql, SQL } else { // Try to find SQLException with actual error code, looping through the causes. - // E.g. applicable to java.sql.DataTruncation as of JDK 1.6. + // For example, applicable to java.sql.DataTruncation as of JDK 1.6. SQLException current = sqlEx; while (current.getErrorCode() == 0 && current.getCause() instanceof SQLException sqlException) { current = sqlException; diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLErrorCodesFactory.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLErrorCodesFactory.java index 66759edeeec5..4b4575cc8e96 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLErrorCodesFactory.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLErrorCodesFactory.java @@ -54,7 +54,7 @@ public class SQLErrorCodesFactory { /** * The name of custom SQL error codes file, loading from the root - * of the class path (e.g. from the "/WEB-INF/classes" directory). + * of the class path (for example, from the "/WEB-INF/classes" directory). */ public static final String SQL_ERROR_CODE_OVERRIDE_PATH = "sql-error-codes.xml"; diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslator.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslator.java index 4d451573e74e..ad68fcf54d40 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslator.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslator.java @@ -72,8 +72,8 @@ public class SQLStateSQLExceptionTranslator extends AbstractFallbackSQLException private static final Set DATA_ACCESS_RESOURCE_FAILURE_CODES = Set.of( "08", // Connection exception - "53", // PostgreSQL: insufficient resources (e.g. disk full) - "54", // PostgreSQL: program limit exceeded (e.g. statement too complex) + "53", // PostgreSQL: insufficient resources (for example, disk full) + "54", // PostgreSQL: program limit exceeded (for example, statement too complex) "57", // DB2: out-of-memory exception / database not started "58" // DB2: unexpected system error ); diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/AbstractColumnMaxValueIncrementer.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/AbstractColumnMaxValueIncrementer.java index 67615dcf79c6..e1cc97c11172 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/AbstractColumnMaxValueIncrementer.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/AbstractColumnMaxValueIncrementer.java @@ -43,6 +43,7 @@ public abstract class AbstractColumnMaxValueIncrementer extends AbstractDataFiel * @see #setIncrementerName * @see #setColumnName */ + @SuppressWarnings("NullAway") public AbstractColumnMaxValueIncrementer() { } diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/AbstractDataFieldMaxValueIncrementer.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/AbstractDataFieldMaxValueIncrementer.java index 8049e868edda..6f761ea9861e 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/AbstractDataFieldMaxValueIncrementer.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/AbstractDataFieldMaxValueIncrementer.java @@ -77,6 +77,7 @@ public void setDataSource(DataSource dataSource) { /** * Return the data source to retrieve the value from. */ + @SuppressWarnings("NullAway") public DataSource getDataSource() { return this.dataSource; } @@ -91,6 +92,7 @@ public void setIncrementerName(String incrementerName) { /** * Return the name of the sequence/table. */ + @SuppressWarnings("NullAway") public String getIncrementerName() { return this.incrementerName; } diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/AbstractIdentityColumnMaxValueIncrementer.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/AbstractIdentityColumnMaxValueIncrementer.java index 5c523a128c32..39b400ad8434 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/AbstractIdentityColumnMaxValueIncrementer.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/AbstractIdentityColumnMaxValueIncrementer.java @@ -53,9 +53,11 @@ public abstract class AbstractIdentityColumnMaxValueIncrementer extends Abstract * @see #setIncrementerName * @see #setColumnName */ + @SuppressWarnings("NullAway") public AbstractIdentityColumnMaxValueIncrementer() { } + @SuppressWarnings("NullAway") public AbstractIdentityColumnMaxValueIncrementer(DataSource dataSource, String incrementerName, String columnName) { super(dataSource, incrementerName, columnName); } diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/MySQLMaxValueIncrementer.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/MySQLMaxValueIncrementer.java index 83eb5bf75e15..7183be4cc8b8 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/MySQLMaxValueIncrementer.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/MySQLMaxValueIncrementer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +54,7 @@ * "useNewConnection" property to false. In this case you MUST use a non-transactional * storage engine like MYISAM when defining the incrementer table. * - *

    As of Spring Framework 5.3.7, {@code MySQLMaxValueIncrementer} is compatible with + *

    Note that {@code MySQLMaxValueIncrementer} is compatible with * MySQL safe updates mode. * * @author Jean-Pierre Pawlak @@ -104,7 +104,7 @@ public MySQLMaxValueIncrementer(DataSource dataSource, String incrementerName, S * {@code false} is sufficient if the storage engine of the sequence table * is non-transactional (like MYISAM), avoiding the effort of acquiring an * extra {@code Connection} for the increment operation. - *

    Default is {@code true} since Spring Framework 5.0. + *

    Default is {@code true}. * @since 4.3.6 * @see DataSource#getConnection() */ diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/AbstractLobHandler.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/AbstractLobHandler.java index 8e1dd9376cf4..d2ff13ec95ef 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/AbstractLobHandler.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/AbstractLobHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2013 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +32,10 @@ * @author Juergen Hoeller * @since 1.2 * @see java.sql.ResultSet#findColumn + * @deprecated as of 6.2, in favor of {@link org.springframework.jdbc.core.support.SqlBinaryValue} + * and {@link org.springframework.jdbc.core.support.SqlCharacterValue} */ +@Deprecated(since = "6.2") public abstract class AbstractLobHandler implements LobHandler { @Override diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/DefaultLobHandler.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/DefaultLobHandler.java index e5cc44390c91..01fe9316befd 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/DefaultLobHandler.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/DefaultLobHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -80,7 +80,10 @@ * @see java.sql.PreparedStatement#setString * @see java.sql.PreparedStatement#setAsciiStream * @see java.sql.PreparedStatement#setCharacterStream + * @deprecated as of 6.2, in favor of {@link org.springframework.jdbc.core.support.SqlBinaryValue} + * and {@link org.springframework.jdbc.core.support.SqlCharacterValue} */ +@Deprecated(since = "6.2") public class DefaultLobHandler extends AbstractLobHandler { protected final Log logger = LogFactory.getLog(getClass()); @@ -99,7 +102,7 @@ public class DefaultLobHandler extends AbstractLobHandler { *

    Default is "false", using the common JDBC 2.0 {@code setBinaryStream} * / {@code setCharacterStream} method for setting the content. Switch this * to "true" for explicit Blob / Clob wrapping against JDBC drivers that - * are known to require such wrapping (e.g. PostgreSQL's for access to OID + * are known to require such wrapping (for example, PostgreSQL's for access to OID * columns, whereas BYTEA columns need to be accessed the standard way). *

    This setting affects byte array / String arguments as well as stream * arguments, unless {@link #setStreamAsLob "streamAsLob"} overrides this @@ -118,7 +121,7 @@ public void setWrapAsLob(boolean wrapAsLob) { *

    Default is "false", using the common JDBC 2.0 {@code setBinaryStream} * / {@code setCharacterStream} method for setting the content. * Switch this to "true" for explicit JDBC 4.0 streaming, provided that your - * JDBC driver actually supports those JDBC 4.0 operations (e.g. Derby's). + * JDBC driver actually supports those JDBC 4.0 operations (for example, Derby's). *

    This setting affects stream arguments as well as byte array / String * arguments, requiring JDBC 4.0 support. For supporting LOB content against * JDBC 3.0, check out the {@link #setWrapAsLob "wrapAsLob"} setting. diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/LobCreator.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/LobCreator.java index 865bf4af9de9..4c59328629aa 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/LobCreator.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/LobCreator.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,10 @@ * @see java.sql.PreparedStatement#setString * @see java.sql.PreparedStatement#setAsciiStream * @see java.sql.PreparedStatement#setCharacterStream + * @deprecated as of 6.2, in favor of {@link org.springframework.jdbc.core.support.SqlBinaryValue} + * and {@link org.springframework.jdbc.core.support.SqlCharacterValue} */ +@Deprecated(since = "6.2") public interface LobCreator extends Closeable { /** diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/LobHandler.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/LobHandler.java index 8bacfea8fe6d..11fea7e50dec 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/LobHandler.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/LobHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +37,7 @@ * which by default delegates to JDBC's direct accessor methods, avoiding the * {@code java.sql.Blob} and {@code java.sql.Clob} API completely. * {@link DefaultLobHandler} can also be configured to access LOBs using - * {@code PreparedStatement.setBlob/setClob} (e.g. for PostgreSQL), through + * {@code PreparedStatement.setBlob/setClob} (for example, for PostgreSQL), through * setting the {@link DefaultLobHandler#setWrapAsLob "wrapAsLob"} property. * *

    Of course, you need to declare different field types for each database. @@ -72,7 +72,10 @@ * @see java.sql.ResultSet#getString * @see java.sql.ResultSet#getAsciiStream * @see java.sql.ResultSet#getCharacterStream + * @deprecated as of 6.2, in favor of {@link org.springframework.jdbc.core.support.SqlBinaryValue} + * and {@link org.springframework.jdbc.core.support.SqlCharacterValue} */ +@Deprecated(since = "6.2") public interface LobHandler { /** diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/PassThroughBlob.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/PassThroughBlob.java index 4a304abea0cb..93238ea41ec0 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/PassThroughBlob.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/PassThroughBlob.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +31,7 @@ * @author Juergen Hoeller * @since 2.5.3 */ +@Deprecated class PassThroughBlob implements Blob { @Nullable diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/PassThroughClob.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/PassThroughClob.java index b2c82d4007be..8dea8a2c842a 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/PassThroughClob.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/PassThroughClob.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,6 +38,7 @@ * @author Juergen Hoeller * @since 2.5.3 */ +@Deprecated class PassThroughClob implements Clob { @Nullable diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/TemporaryLobCreator.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/TemporaryLobCreator.java index 0c0bed045f3f..7a0e18db9ca3 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/TemporaryLobCreator.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/lob/TemporaryLobCreator.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,10 @@ * @see DefaultLobHandler#setCreateTemporaryLob * @see java.sql.Connection#createBlob() * @see java.sql.Connection#createClob() + * @deprecated as of 6.2, in favor of {@link org.springframework.jdbc.core.support.SqlBinaryValue} + * and {@link org.springframework.jdbc.core.support.SqlCharacterValue} */ +@Deprecated(since = "6.2") public class TemporaryLobCreator implements LobCreator { protected static final Log logger = LogFactory.getLog(TemporaryLobCreator.class); diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/rowset/ResultSetWrappingSqlRowSet.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/rowset/ResultSetWrappingSqlRowSet.java index 56d3accb10d7..04d90e000cba 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/rowset/ResultSetWrappingSqlRowSet.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/rowset/ResultSetWrappingSqlRowSet.java @@ -30,6 +30,7 @@ import org.springframework.jdbc.InvalidResultSetAccessException; import org.springframework.lang.Nullable; import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; /** * The common implementation of Spring's {@link SqlRowSet} interface, wrapping a @@ -99,7 +100,7 @@ public ResultSetWrappingSqlRowSet(ResultSet resultSet) throws InvalidResultSetAc ResultSetMetaData rsmd = resultSet.getMetaData(); if (rsmd != null) { int columnCount = rsmd.getColumnCount(); - this.columnLabelMap = CollectionUtils.newHashMap(columnCount); + this.columnLabelMap = CollectionUtils.newHashMap(columnCount * 2); for (int i = 1; i <= columnCount; i++) { String key = rsmd.getColumnLabel(i); // Make sure to preserve first matching column for any given name, @@ -107,6 +108,15 @@ public ResultSetWrappingSqlRowSet(ResultSet resultSet) throws InvalidResultSetAc if (!this.columnLabelMap.containsKey(key)) { this.columnLabelMap.put(key, i); } + // Also support column names prefixed with table name + // as in {table_name}.{column.name}. + String table = rsmd.getTableName(i); + if (StringUtils.hasLength(table)) { + key = table + "." + rsmd.getColumnName(i); + if (!this.columnLabelMap.containsKey(key)) { + this.columnLabelMap.put(key, i); + } + } } } else { diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/Jdbc4SqlXmlHandler.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/Jdbc4SqlXmlHandler.java index a79fb2723ee9..9a5775c71206 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/Jdbc4SqlXmlHandler.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/Jdbc4SqlXmlHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2024 the original author 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.io.InputStream; import java.io.Reader; +import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -46,7 +47,11 @@ * @see java.sql.SQLXML * @see java.sql.ResultSet#getSQLXML * @see java.sql.PreparedStatement#setSQLXML + * @deprecated as of 6.2, in favor of direct {@link ResultSet#getSQLXML} and + * {@link Connection#createSQLXML()} usage, possibly in combination with a + * custom {@link org.springframework.jdbc.support.SqlValue} implementation */ +@Deprecated(since = "6.2") public class Jdbc4SqlXmlHandler implements SqlXmlHandler { //------------------------------------------------------------------------- diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/SqlXmlFeatureNotImplementedException.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/SqlXmlFeatureNotImplementedException.java index 443f4bfd0851..c9abffb57ed5 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/SqlXmlFeatureNotImplementedException.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/SqlXmlFeatureNotImplementedException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2012 the original author or authors. + * Copyright 2002-2024 the original author 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,9 @@ package org.springframework.jdbc.support.xml; +import java.sql.Connection; +import java.sql.ResultSet; + import org.springframework.dao.InvalidDataAccessApiUsageException; /** @@ -24,7 +27,11 @@ * * @author Thomas Risberg * @since 2.5.5 + * @deprecated as of 6.2, in favor of direct {@link ResultSet#getSQLXML} and + * {@link Connection#createSQLXML()} usage, possibly in combination with a + * custom {@link org.springframework.jdbc.support.SqlValue} implementation */ +@Deprecated(since = "6.2") @SuppressWarnings("serial") public class SqlXmlFeatureNotImplementedException extends InvalidDataAccessApiUsageException { diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/SqlXmlHandler.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/SqlXmlHandler.java index e4f7318fc626..de37b3b95b39 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/SqlXmlHandler.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/SqlXmlHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +18,7 @@ import java.io.InputStream; import java.io.Reader; +import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; @@ -45,7 +46,11 @@ * @see java.sql.SQLXML * @see java.sql.ResultSet#getSQLXML * @see java.sql.PreparedStatement#setSQLXML + * @deprecated as of 6.2, in favor of direct {@link ResultSet#getSQLXML} and + * {@link Connection#createSQLXML()} usage, possibly in combination with a + * custom {@link org.springframework.jdbc.support.SqlValue} implementation */ +@Deprecated(since = "6.2") public interface SqlXmlHandler { //------------------------------------------------------------------------- diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/SqlXmlValue.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/SqlXmlValue.java index d662a9271ae3..5039898cbf03 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/SqlXmlValue.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/SqlXmlValue.java @@ -25,7 +25,9 @@ * @author Thomas Risberg * @since 2.5.5 * @see org.springframework.jdbc.support.SqlValue + * @deprecated as of 6.2, in favor of a direct {@link SqlValue} implementation */ +@Deprecated(since = "6.2") public interface SqlXmlValue extends SqlValue { } diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/XmlBinaryStreamProvider.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/XmlBinaryStreamProvider.java index 1926b9d62445..916c9776843b 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/XmlBinaryStreamProvider.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/XmlBinaryStreamProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2012 the original author or authors. + * Copyright 2002-2024 the original author 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,9 @@ * @author Thomas Risberg * @since 2.5.5 * @see java.io.OutputStream + * @deprecated as of 6.2, in favor of direct {@link java.sql.SQLXML} usage */ +@Deprecated(since = "6.2") public interface XmlBinaryStreamProvider { /** diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/XmlCharacterStreamProvider.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/XmlCharacterStreamProvider.java index 8b2bf69f042f..74a4fab5c0fa 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/XmlCharacterStreamProvider.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/XmlCharacterStreamProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2012 the original author or authors. + * Copyright 2002-2024 the original author 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,9 @@ * @author Thomas Risberg * @since 2.5.5 * @see java.io.Writer + * @deprecated as of 6.2, in favor of direct {@link java.sql.SQLXML} usage */ +@Deprecated(since = "6.2") public interface XmlCharacterStreamProvider { /** diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/XmlResultProvider.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/XmlResultProvider.java index 0c3eb05a8102..accef5662290 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/XmlResultProvider.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/xml/XmlResultProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2012 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +25,9 @@ * @author Thomas Risberg * @since 2.5.5 * @see javax.xml.transform.Result + * @deprecated as of 6.2, in favor of direct {@link java.sql.SQLXML} usage */ +@Deprecated(since = "6.2") public interface XmlResultProvider { /** diff --git a/spring-jdbc/src/main/kotlin/org/springframework/jdbc/core/JdbcOperationsExtensions.kt b/spring-jdbc/src/main/kotlin/org/springframework/jdbc/core/JdbcOperationsExtensions.kt index 08c7e04174c4..1a7b808c23fb 100644 --- a/spring-jdbc/src/main/kotlin/org/springframework/jdbc/core/JdbcOperationsExtensions.kt +++ b/spring-jdbc/src/main/kotlin/org/springframework/jdbc/core/JdbcOperationsExtensions.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-jdbc/src/main/resources/org/springframework/jdbc/config/spring-jdbc.xsd b/spring-jdbc/src/main/resources/org/springframework/jdbc/config/spring-jdbc.xsd index 06d253f99bae..29f390c7203c 100644 --- a/spring-jdbc/src/main/resources/org/springframework/jdbc/config/spring-jdbc.xsd +++ b/spring-jdbc/src/main/resources/org/springframework/jdbc/config/spring-jdbc.xsd @@ -159,7 +159,7 @@ diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/BeanPropertyRowMapperTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/BeanPropertyRowMapperTests.java index f7108e3e46b8..ea0a23467441 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/BeanPropertyRowMapperTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/BeanPropertyRowMapperTests.java @@ -204,6 +204,7 @@ void underscoreName(String input, String expected) { private static class CustomPerson extends Person { + @Override @MyColumnName("birthdate") public void setBirth_date(Date date) { super.setBirth_date(date); diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/JdbcTemplateTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/JdbcTemplateTests.java index 70e15f553168..4848f4709feb 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/JdbcTemplateTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/JdbcTemplateTests.java @@ -32,13 +32,16 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import javax.sql.DataSource; +import org.assertj.core.data.Index; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.dao.DataAccessException; +import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.jdbc.BadSqlGrammarException; import org.springframework.jdbc.CannotGetJdbcConnectionException; @@ -799,6 +802,55 @@ void testBatchUpdateWithCollectionOfObjects() throws Exception { verify(this.connection, atLeastOnce()).close(); } + @Test + void testBatchUpdateWithBatchFailingHasUpdateCounts() throws Exception { + test3BatchesOf2ItemsFailing(exception -> assertThat(exception).cause() + .isInstanceOfSatisfying(AggregatedBatchUpdateException.class, ex -> { + assertThat(ex.getSuccessfulUpdateCounts()).hasDimensions(1, 2) + .contains(new int[] { 1, 1 }, Index.atIndex(0)); + assertThat(ex.getUpdateCounts()).contains(-3, -3); + })); + } + + @Test + void testBatchUpdateWithBatchFailingMatchesOriginalException() throws Exception { + test3BatchesOf2ItemsFailing(exception -> assertThat(exception).cause() + .isInstanceOfSatisfying(AggregatedBatchUpdateException.class, ex -> { + BatchUpdateException originalException = ex.getOriginalException(); + assertThat(ex.getMessage()).isEqualTo(originalException.getMessage()); + assertThat(ex.getCause()).isEqualTo(originalException.getCause()); + assertThat(ex.getSQLState()).isEqualTo(originalException.getSQLState()); + assertThat(ex.getErrorCode()).isEqualTo(originalException.getErrorCode()); + assertThat((Exception) ex.getNextException()).isSameAs(originalException.getNextException()); + assertThat(ex.getSuppressed()).isEqualTo(originalException.getSuppressed()); + })); + } + + void test3BatchesOf2ItemsFailing(Consumer exception) throws Exception { + String sql = "INSERT INTO NOSUCHTABLE values (?)"; + List ids = Arrays.asList(1, 2, 3, 2, 4, 5); + int[] rowsAffected = new int[] {1, 1}; + + given(this.preparedStatement.executeBatch()).willReturn(rowsAffected).willThrow(new BatchUpdateException( + "duplicate key value violates unique constraint \"NOSUCHTABLE_pkey\" Detail: Key (id)=(2) already exists.", + "23505", 0, new int[] { -3, -3 })); + mockDatabaseMetaData(true); + + ParameterizedPreparedStatementSetter setter = (ps, argument) -> ps.setInt(1, argument); + JdbcTemplate template = new JdbcTemplate(this.dataSource, false); + + assertThatExceptionOfType(DuplicateKeyException.class) + .isThrownBy(() -> template.batchUpdate(sql, ids, 2, setter)) + .satisfies(exception); + verify(this.preparedStatement, times(4)).addBatch(); + verify(this.preparedStatement).setInt(1, 1); + verify(this.preparedStatement, times(2)).setInt(1, 2); + verify(this.preparedStatement).setInt(1, 3); + verify(this.preparedStatement, times(2)).executeBatch(); + verify(this.preparedStatement).close(); + verify(this.connection, atLeastOnce()).close(); + } + @Test void testCouldNotGetConnectionForOperationOrExceptionTranslator() throws SQLException { SQLException sqlException = new SQLException("foo", "07xxx"); diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/StatementCreatorUtilsTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/StatementCreatorUtilsTests.java index 31b5e4320e70..b46065d3c63f 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/StatementCreatorUtilsTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/StatementCreatorUtilsTests.java @@ -218,14 +218,14 @@ void testSetParameterValueWithCalendarAndUnknownType() throws SQLException { verify(preparedStatement).setTimestamp(1, new java.sql.Timestamp(cal.getTime().getTime()), cal); } - @ParameterizedTest + @ParameterizedTest(name = "{0} -> {1}") @MethodSource("javaTimeTypes") public void testSetParameterValueWithJavaTimeTypes(Object o, int sqlType) throws SQLException { StatementCreatorUtils.setParameterValue(preparedStatement, 1, sqlType, o); verify(preparedStatement).setObject(1, o, sqlType); } - @ParameterizedTest + @ParameterizedTest(name = "{0} -> {1}") @MethodSource("javaTimeTypes") void javaTimeTypesToSqlParameterType(Object o, int expectedSqlType) { assertThat(StatementCreatorUtils.javaTypeToSqlParameterType(o.getClass())) diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/metadata/GenericCallMetaDataProviderTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/metadata/GenericCallMetaDataProviderTests.java index 7370f6cc6784..a6c318ef1a38 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/metadata/GenericCallMetaDataProviderTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/metadata/GenericCallMetaDataProviderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/namedparam/NamedParameterUtilsTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/namedparam/NamedParameterUtilsTests.java index 699094e2a7bc..ec80265beda4 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/namedparam/NamedParameterUtilsTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/namedparam/NamedParameterUtilsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 @@ import java.util.Map; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.jdbc.core.SqlParameterValue; @@ -151,7 +153,7 @@ void testParseSqlStatementWithStringContainingQuotes() { } @Test // SPR-4789 - public void parseSqlContainingComments() { + void parseSqlContainingComments() { String sql1 = "/*+ HINT */ xxx /* comment ? */ :a yyyy :b :c :a zzzzz -- :xx XX\n"; ParsedSql parsedSql1 = NamedParameterUtils.parseSqlStatement(sql1); assertThat(NamedParameterUtils.substituteNamedParameters(parsedSql1, null)).isEqualTo("/*+ HINT */ xxx /* comment ? */ ? yyyy ? ? ? zzzzz -- :xx XX\n"); @@ -177,7 +179,7 @@ public void parseSqlContainingComments() { } @Test // SPR-4612 - public void parseSqlStatementWithPostgresCasting() { + void parseSqlStatementWithPostgresCasting() { String expectedSql = "select 'first name' from artists where id = ? and birth_date=?::timestamp"; String sql = "select 'first name' from artists where id = :id and birth_date=:birthDate::timestamp"; ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql); @@ -185,7 +187,7 @@ public void parseSqlStatementWithPostgresCasting() { } @Test // SPR-13582 - public void parseSqlStatementWithPostgresContainedOperator() { + void parseSqlStatementWithPostgresContainedOperator() { String expectedSql = "select 'first name' from artists where info->'stat'->'albums' = ?? ? and '[\"1\",\"2\",\"3\"]'::jsonb ?? '4'"; String sql = "select 'first name' from artists where info->'stat'->'albums' = ?? :album and '[\"1\",\"2\",\"3\"]'::jsonb ?? '4'"; ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql); @@ -194,7 +196,7 @@ public void parseSqlStatementWithPostgresContainedOperator() { } @Test // SPR-15382 - public void parseSqlStatementWithPostgresAnyArrayStringsExistsOperator() { + void parseSqlStatementWithPostgresAnyArrayStringsExistsOperator() { String expectedSql = "select '[\"3\", \"11\"]'::jsonb ?| '{1,3,11,12,17}'::text[]"; String sql = "select '[\"3\", \"11\"]'::jsonb ?| '{1,3,11,12,17}'::text[]"; @@ -204,7 +206,7 @@ public void parseSqlStatementWithPostgresAnyArrayStringsExistsOperator() { } @Test // SPR-15382 - public void parseSqlStatementWithPostgresAllArrayStringsExistsOperator() { + void parseSqlStatementWithPostgresAllArrayStringsExistsOperator() { String expectedSql = "select '[\"3\", \"11\"]'::jsonb ?& '{1,3,11,12,17}'::text[] AND ? = 'Back in Black'"; String sql = "select '[\"3\", \"11\"]'::jsonb ?& '{1,3,11,12,17}'::text[] AND :album = 'Back in Black'"; @@ -214,7 +216,7 @@ public void parseSqlStatementWithPostgresAllArrayStringsExistsOperator() { } @Test // SPR-7476 - public void parseSqlStatementWithEscapedColon() { + void parseSqlStatementWithEscapedColon() { String expectedSql = "select '0\\:0' as a, foo from bar where baz < DATE(? 23:59:59) and baz = ?"; String sql = "select '0\\:0' as a, foo from bar where baz < DATE(:p1 23\\:59\\:59) and baz = :p2"; @@ -225,7 +227,7 @@ public void parseSqlStatementWithEscapedColon() { } @Test // SPR-7476 - public void parseSqlStatementWithBracketDelimitedParameterNames() { + void parseSqlStatementWithBracketDelimitedParameterNames() { String expectedSql = "select foo from bar where baz = b??z"; String sql = "select foo from bar where baz = b:{p1}:{p2}z"; @@ -236,7 +238,7 @@ public void parseSqlStatementWithBracketDelimitedParameterNames() { } @Test // SPR-7476 - public void parseSqlStatementWithEmptyBracketsOrBracketsInQuotes() { + void parseSqlStatementWithEmptyBracketsOrBracketsInQuotes() { String expectedSql = "select foo from bar where baz = b:{}z"; String sql = "select foo from bar where baz = b:{}z"; ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql); @@ -265,52 +267,42 @@ void parseSqlStatementWithSingleLetterInBrackets() { } @Test // SPR-2544 - public void parseSqlStatementWithLogicalAnd() { + void parseSqlStatementWithLogicalAnd() { String expectedSql = "xxx & yyyy"; ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(expectedSql); assertThat(substituteNamedParameters(parsedSql)).isEqualTo(expectedSql); } @Test // SPR-2544 - public void substituteNamedParametersWithLogicalAnd() { + void substituteNamedParametersWithLogicalAnd() { String expectedSql = "xxx & yyyy"; String newSql = NamedParameterUtils.substituteNamedParameters(expectedSql, new MapSqlParameterSource()); assertThat(newSql).isEqualTo(expectedSql); } @Test // SPR-3173 - public void variableAssignmentOperator() { + void variableAssignmentOperator() { String expectedSql = "x := 1"; String newSql = NamedParameterUtils.substituteNamedParameters(expectedSql, new MapSqlParameterSource()); assertThat(newSql).isEqualTo(expectedSql); } - @Test // SPR-8280 - public void parseSqlStatementWithQuotedSingleQuote() { - String sql = "SELECT ':foo'':doo', :xxx FROM DUAL"; - ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql); - assertThat(parsedSql.getTotalParameterCount()).isEqualTo(1); - assertThat(parsedSql.getParameterNames()).containsExactly("xxx"); - } - - @Test - void parseSqlStatementWithQuotesAndCommentBefore() { - String sql = "SELECT /*:doo*/':foo', :xxx FROM DUAL"; - ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql); - assertThat(parsedSql.getTotalParameterCount()).isEqualTo(1); - assertThat(parsedSql.getParameterNames()).containsExactly("xxx"); - } - - @Test - void parseSqlStatementWithQuotesAndCommentAfter() { - String sql = "SELECT ':foo'/*:doo*/, :xxx FROM DUAL"; + @ParameterizedTest // SPR-8280 and others + @ValueSource(strings = { + "SELECT ':foo'':doo', :xxx FROM DUAL", + "SELECT /*:doo*/':foo', :xxx FROM DUAL", + "SELECT ':foo'/*:doo*/, :xxx FROM DUAL", + "SELECT \":foo\"\":doo\", :xxx FROM DUAL", + "SELECT `:foo``:doo`, :xxx FROM DUAL" + }) + void parseSqlStatementWithParametersInsideQuotesAndComments(String sql) { ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql); assertThat(parsedSql.getTotalParameterCount()).isEqualTo(1); assertThat(parsedSql.getParameterNames()).containsExactly("xxx"); } @Test // gh-27716 - public void parseSqlStatementWithSquareBracket() { + void parseSqlStatementWithSquareBracket() { String sql = "SELECT ARRAY[:ext]"; ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql); assertThat(parsedSql.getNamedParameterCount()).isEqualTo(1); @@ -361,6 +353,14 @@ public Map getHeaders() { assertThat(sqlToUse).isEqualTo("insert into foos (id) values (?)"); } + @Test // gh-31944 + void parseSqlStatementWithBackticks() { + String sql = "select * from `tb&user` where id = :id"; + ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql); + assertThat(parsedSql.getParameterNames()).containsExactly("id"); + assertThat(substituteNamedParameters(parsedSql)).isEqualTo("select * from `tb&user` where id = ?"); + } + private static String substituteNamedParameters(ParsedSql parsedSql) { return NamedParameterUtils.substituteNamedParameters(parsedSql, null); } diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientQueryTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientQueryTests.java index a4bbeaa3c653..a8212b4a6931 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientQueryTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientQueryTests.java @@ -174,6 +174,40 @@ void queryForIntegerWithIndexedParamAndSingleValue() throws Exception { verify(connection).close(); } + @Test + void queryForIntegerWithIndexedParamAndOptionalValue() throws Exception { + given(resultSet.next()).willReturn(true, false); + given(resultSet.getObject(1)).willReturn(22); + + Optional value = client.sql("SELECT AGE FROM CUSTMR WHERE ID = ?") + .param(1, 3) + .query().optionalValue(); + + assertThat(value.isPresent()).isTrue(); + assertThat(value.get()).isEqualTo(22); + verify(connection).prepareStatement("SELECT AGE FROM CUSTMR WHERE ID = ?"); + verify(preparedStatement).setObject(1, 3); + verify(resultSet).close(); + verify(preparedStatement).close(); + verify(connection).close(); + } + + @Test + void queryForIntegerWithIndexedParamAndNonExistingValue() throws Exception { + given(resultSet.next()).willReturn(false); + + Optional value = client.sql("SELECT AGE FROM CUSTMR WHERE ID = ?") + .param(1, 3) + .query().optionalValue(); + + assertThat(value.isPresent()).isFalse(); + verify(connection).prepareStatement("SELECT AGE FROM CUSTMR WHERE ID = ?"); + verify(preparedStatement).setObject(1, 3); + verify(resultSet).close(); + verify(preparedStatement).close(); + verify(connection).close(); + } + @Test void queryForIntegerWithIndexedParamAndRowMapper() throws Exception { given(resultSet.next()).willReturn(true, false); diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/support/LobSupportTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/support/LobSupportTests.java index cc6559af013e..66e1aaa7f021 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/support/LobSupportTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/support/LobSupportTests.java @@ -37,6 +37,7 @@ /** * @author Alef Arendsen */ +@SuppressWarnings("deprecation") class LobSupportTests { @Test diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/support/SqlLobValueTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/support/SqlLobValueTests.java index e4b3e26cfc24..20d249196d9c 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/support/SqlLobValueTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/support/SqlLobValueTests.java @@ -60,6 +60,7 @@ */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) +@SuppressWarnings("deprecation") class SqlLobValueTests { @Mock diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/DataSourceTransactionManagerTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/DataSourceTransactionManagerTests.java index 02f87c4d6901..81cc8142e1c8 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/DataSourceTransactionManagerTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/DataSourceTransactionManagerTests.java @@ -127,7 +127,7 @@ void testTransactionCommitWithAutoCommitFalseAndLazyConnectionAndStatementCreate } private void doTestTransactionCommitRestoringAutoCommit( - boolean autoCommit, boolean lazyConnection, final boolean createStatement) throws Exception { + boolean autoCommit, boolean lazyConnection, boolean createStatement) throws Exception { given(con.getAutoCommit()).willReturn(autoCommit); @@ -136,7 +136,7 @@ private void doTestTransactionCommitRestoringAutoCommit( given(con.getWarnings()).willThrow(new SQLException()); } - final DataSource dsToUse = (lazyConnection ? new LazyConnectionDataSourceProxy(ds) : ds); + DataSource dsToUse = (lazyConnection ? new LazyConnectionDataSourceProxy(ds) : ds); tm = createTransactionManager(dsToUse); TransactionTemplate tt = new TransactionTemplate(tm); assertThat(TransactionSynchronizationManager.hasResource(dsToUse)).isFalse(); @@ -214,7 +214,7 @@ void testTransactionRollbackWithAutoCommitFalseAndLazyConnectionAndCreateStateme } private void doTestTransactionRollbackRestoringAutoCommit( - boolean autoCommit, boolean lazyConnection, final boolean createStatement) throws Exception { + boolean autoCommit, boolean lazyConnection, boolean createStatement) throws Exception { given(con.getAutoCommit()).willReturn(autoCommit); @@ -222,13 +222,13 @@ private void doTestTransactionRollbackRestoringAutoCommit( given(con.getTransactionIsolation()).willReturn(Connection.TRANSACTION_READ_COMMITTED); } - final DataSource dsToUse = (lazyConnection ? new LazyConnectionDataSourceProxy(ds) : ds); + DataSource dsToUse = (lazyConnection ? new LazyConnectionDataSourceProxy(ds) : ds); tm = createTransactionManager(dsToUse); TransactionTemplate tt = new TransactionTemplate(tm); assertThat(TransactionSynchronizationManager.hasResource(dsToUse)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); - final RuntimeException ex = new RuntimeException("Application exception"); + RuntimeException ex = new RuntimeException("Application exception"); assertThatRuntimeException().isThrownBy(() -> tt.execute(new TransactionCallbackWithoutResult() { @Override @@ -276,7 +276,7 @@ void testTransactionRollbackOnly() { ConnectionHolder conHolder = new ConnectionHolder(con, true); TransactionSynchronizationManager.bindResource(ds, conHolder); - final RuntimeException ex = new RuntimeException("Application exception"); + RuntimeException ex = new RuntimeException("Application exception"); try { tt.execute(new TransactionCallbackWithoutResult() { @Override @@ -328,7 +328,7 @@ private void doTestParticipatingTransactionWithRollbackOnly(boolean failEarly) t try { assertThat(ts.isNewTransaction()).isTrue(); - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) throws RuntimeException { @@ -383,8 +383,8 @@ void testParticipatingTransactionWithIncompatibleIsolationLevel() throws Excepti assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); assertThatExceptionOfType(IllegalTransactionStateException.class).isThrownBy(() -> { - final TransactionTemplate tt = new TransactionTemplate(tm); - final TransactionTemplate tt2 = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt2 = new TransactionTemplate(tm); tt2.setIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE); tt.execute(new TransactionCallbackWithoutResult() { @@ -416,9 +416,9 @@ void testParticipatingTransactionWithIncompatibleReadOnly() throws Exception { assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); assertThatExceptionOfType(IllegalTransactionStateException.class).isThrownBy(() -> { - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setReadOnly(true); - final TransactionTemplate tt2 = new TransactionTemplate(tm); + TransactionTemplate tt2 = new TransactionTemplate(tm); tt2.setReadOnly(false); tt.execute(new TransactionCallbackWithoutResult() { @@ -446,10 +446,10 @@ void testParticipatingTransactionWithTransactionStartedFromSynch() throws Except assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); - final TestTransactionSynchronization synch = + TestTransactionSynchronization synch = new TestTransactionSynchronization(ds, TransactionSynchronization.STATUS_COMMITTED) { @Override protected void doAfterCompletion(int status) { @@ -483,15 +483,15 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run @Test void testParticipatingTransactionWithDifferentConnectionObtainedFromSynch() throws Exception { DataSource ds2 = mock(); - final Connection con2 = mock(); + Connection con2 = mock(); given(ds2.getConnection()).willReturn(con2); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); - final TestTransactionSynchronization synch = + TestTransactionSynchronization synch = new TestTransactionSynchronization(ds, TransactionSynchronization.STATUS_COMMITTED) { @Override protected void doAfterCompletion(int status) { @@ -529,12 +529,12 @@ void testParticipatingTransactionWithRollbackOnlyAndInnerSynch() throws Exceptio assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); TransactionStatus ts = tm.getTransaction(new DefaultTransactionDefinition()); - final TestTransactionSynchronization synch = + TestTransactionSynchronization synch = new TestTransactionSynchronization(ds, TransactionSynchronization.STATUS_UNKNOWN); assertThatExceptionOfType(UnexpectedRollbackException.class).isThrownBy(() -> { assertThat(ts.isNewTransaction()).isTrue(); - final TransactionTemplate tt = new TransactionTemplate(tm2); + TransactionTemplate tt = new TransactionTemplate(tm2); tt.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) throws RuntimeException { @@ -569,7 +569,7 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run @Test void testPropagationRequiresNewWithExistingTransaction() throws Exception { - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); @@ -607,14 +607,14 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run @Test void testPropagationRequiresNewWithExistingTransactionAndUnrelatedDataSource() throws Exception { Connection con2 = mock(); - final DataSource ds2 = mock(); + DataSource ds2 = mock(); given(ds2.getConnection()).willReturn(con2); - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); PlatformTransactionManager tm2 = createTransactionManager(ds2); - final TransactionTemplate tt2 = new TransactionTemplate(tm2); + TransactionTemplate tt2 = new TransactionTemplate(tm2); tt2.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); @@ -655,16 +655,16 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run @Test void testPropagationRequiresNewWithExistingTransactionAndUnrelatedFailingDataSource() throws Exception { - final DataSource ds2 = mock(); + DataSource ds2 = mock(); SQLException failure = new SQLException(); given(ds2.getConnection()).willThrow(failure); - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); DataSourceTransactionManager tm2 = createTransactionManager(ds2); tm2.setTransactionSynchronization(DataSourceTransactionManager.SYNCHRONIZATION_NEVER); - final TransactionTemplate tt2 = new TransactionTemplate(tm2); + TransactionTemplate tt2 = new TransactionTemplate(tm2); tt2.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); @@ -699,7 +699,7 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run @Test void testPropagationNotSupportedWithExistingTransaction() throws Exception { - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); @@ -740,7 +740,7 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run @Test void testPropagationNeverWithExistingTransaction() throws Exception { - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); @@ -806,11 +806,10 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run @Test void testPropagationSupportsAndRequiresNewWithEarlyAccess() throws Exception { - final Connection con1 = mock(); - final Connection con2 = mock(); + Connection con1 = mock(); + Connection con2 = mock(); given(ds.getConnection()).willReturn(con1, con2); - final TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_SUPPORTS); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); @@ -1132,7 +1131,7 @@ protected void doInTransactionWithoutResult(TransactionStatus status) { void testTransactionAwareDataSourceProxyWithSuspension() throws Exception { given(con.getAutoCommit()).willReturn(true); - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionTemplate.PROPAGATION_REQUIRES_NEW); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); @@ -1141,7 +1140,7 @@ void testTransactionAwareDataSourceProxyWithSuspension() throws Exception { protected void doInTransactionWithoutResult(TransactionStatus status) { // something transactional assertThat(DataSourceUtils.getConnection(ds)).isEqualTo(con); - final TransactionAwareDataSourceProxy dsProxy = new TransactionAwareDataSourceProxy(ds); + TransactionAwareDataSourceProxy dsProxy = new TransactionAwareDataSourceProxy(ds); try { assertThat(((ConnectionProxy) dsProxy.getConnection()).getTargetConnection()).isEqualTo(con); // should be ignored @@ -1190,7 +1189,7 @@ protected void doInTransactionWithoutResult(TransactionStatus status) { void testTransactionAwareDataSourceProxyWithSuspensionAndReobtaining() throws Exception { given(con.getAutoCommit()).willReturn(true); - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionTemplate.PROPAGATION_REQUIRES_NEW); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); @@ -1199,7 +1198,7 @@ void testTransactionAwareDataSourceProxyWithSuspensionAndReobtaining() throws Ex protected void doInTransactionWithoutResult(TransactionStatus status) { // something transactional assertThat(DataSourceUtils.getConnection(ds)).isEqualTo(con); - final TransactionAwareDataSourceProxy dsProxy = new TransactionAwareDataSourceProxy(ds); + TransactionAwareDataSourceProxy dsProxy = new TransactionAwareDataSourceProxy(ds); dsProxy.setReobtainTransactionalConnections(true); try { assertThat(((ConnectionProxy) dsProxy.getConnection()).getTargetConnection()).isEqualTo(con); @@ -1395,7 +1394,7 @@ void testExistingTransactionWithPropagationNestedTwice() throws Exception { doTestExistingTransactionWithPropagationNested(2); } - private void doTestExistingTransactionWithPropagationNested(final int count) throws Exception { + private void doTestExistingTransactionWithPropagationNested(int count) throws Exception { DatabaseMetaData md = mock(); Savepoint sp = mock(); @@ -1405,7 +1404,7 @@ private void doTestExistingTransactionWithPropagationNested(final int count) thr given(con.setSavepoint(ConnectionHolder.SAVEPOINT_NAME_PREFIX + i)).willReturn(sp); } - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); @@ -1417,6 +1416,8 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run assertThat(status.isNewTransaction()).isTrue(); assertThat(status.isNested()).isFalse(); assertThat(status.hasSavepoint()).isFalse(); + TestSavepointSynchronization synch = new TestSavepointSynchronization(); + TransactionSynchronizationManager.registerSynchronization(synch); for (int i = 0; i < count; i++) { tt.execute(new TransactionCallbackWithoutResult() { @Override @@ -1427,8 +1428,11 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run assertThat(status.isNewTransaction()).isFalse(); assertThat(status.isNested()).isTrue(); assertThat(status.hasSavepoint()).isTrue(); + assertThat(synch.savepointCalled).isTrue(); } }); + assertThat(synch.savepointRollbackCalled).isFalse(); + synch.savepointCalled = false; } assertThat(status.hasTransaction()).isTrue(); assertThat(status.isNewTransaction()).isTrue(); @@ -1452,7 +1456,7 @@ void testExistingTransactionWithPropagationNestedAndRollback() throws Exception given(con.getMetaData()).willReturn(md); given(con.setSavepoint("SAVEPOINT_1")).willReturn(sp); - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); @@ -1464,6 +1468,8 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run assertThat(status.isNewTransaction()).isTrue(); assertThat(status.isNested()).isFalse(); assertThat(status.hasSavepoint()).isFalse(); + TestSavepointSynchronization synch = new TestSavepointSynchronization(); + TransactionSynchronizationManager.registerSynchronization(synch); tt.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) throws RuntimeException { @@ -1473,9 +1479,12 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run assertThat(status.isNewTransaction()).isFalse(); assertThat(status.isNested()).isTrue(); assertThat(status.hasSavepoint()).isTrue(); + assertThat(synch.savepointCalled).isTrue(); + assertThat(synch.savepointRollbackCalled).isFalse(); status.setRollbackOnly(); } }); + assertThat(synch.savepointRollbackCalled).isTrue(); assertThat(status.hasTransaction()).isTrue(); assertThat(status.isNewTransaction()).isTrue(); assertThat(status.isNested()).isFalse(); @@ -1499,7 +1508,7 @@ void testExistingTransactionWithPropagationNestedAndRequiredRollback() throws Ex given(con.getMetaData()).willReturn(md); given(con.setSavepoint("SAVEPOINT_1")).willReturn(sp); - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); @@ -1511,6 +1520,8 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run assertThat(status.isNewTransaction()).isTrue(); assertThat(status.isNested()).isFalse(); assertThat(status.hasSavepoint()).isFalse(); + TestSavepointSynchronization synch = new TestSavepointSynchronization(); + TransactionSynchronizationManager.registerSynchronization(synch); assertThatIllegalStateException().isThrownBy(() -> tt.execute(new TransactionCallbackWithoutResult() { @Override @@ -1521,6 +1532,8 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run assertThat(status.isNewTransaction()).isFalse(); assertThat(status.isNested()).isTrue(); assertThat(status.hasSavepoint()).isTrue(); + assertThat(synch.savepointCalled).isTrue(); + assertThat(synch.savepointRollbackCalled).isFalse(); TransactionTemplate ntt = new TransactionTemplate(tm); ntt.execute(new TransactionCallbackWithoutResult() { @Override @@ -1536,6 +1549,7 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run }); } })); + assertThat(synch.savepointRollbackCalled).isTrue(); assertThat(status.hasTransaction()).isTrue(); assertThat(status.isNewTransaction()).isTrue(); assertThat(status.isNested()).isFalse(); @@ -1559,7 +1573,7 @@ void testExistingTransactionWithPropagationNestedAndRequiredRollbackOnly() throw given(con.getMetaData()).willReturn(md); given(con.setSavepoint("SAVEPOINT_1")).willReturn(sp); - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); @@ -1571,6 +1585,8 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run assertThat(status.isNewTransaction()).isTrue(); assertThat(status.isNested()).isFalse(); assertThat(status.hasSavepoint()).isFalse(); + TestSavepointSynchronization synch = new TestSavepointSynchronization(); + TransactionSynchronizationManager.registerSynchronization(synch); assertThatExceptionOfType(UnexpectedRollbackException.class).isThrownBy(() -> tt.execute(new TransactionCallbackWithoutResult() { @Override @@ -1581,6 +1597,8 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run assertThat(status.isNewTransaction()).isFalse(); assertThat(status.isNested()).isTrue(); assertThat(status.hasSavepoint()).isTrue(); + assertThat(synch.savepointCalled).isTrue(); + assertThat(synch.savepointRollbackCalled).isFalse(); TransactionTemplate ntt = new TransactionTemplate(tm); ntt.execute(new TransactionCallbackWithoutResult() { @Override @@ -1596,6 +1614,7 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run }); } })); + assertThat(synch.savepointRollbackCalled).isTrue(); assertThat(status.hasTransaction()).isTrue(); assertThat(status.isNewTransaction()).isTrue(); assertThat(status.isNested()).isFalse(); @@ -1619,7 +1638,7 @@ void testExistingTransactionWithManualSavepoint() throws Exception { given(con.getMetaData()).willReturn(md); given(con.setSavepoint("SAVEPOINT_1")).willReturn(sp); - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); @@ -1631,8 +1650,12 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run assertThat(status.isNewTransaction()).isTrue(); assertThat(status.isNested()).isFalse(); assertThat(status.hasSavepoint()).isFalse(); + TestSavepointSynchronization synch = new TestSavepointSynchronization(); + TransactionSynchronizationManager.registerSynchronization(synch); Object savepoint = status.createSavepoint(); + assertThat(synch.savepointCalled).isTrue(); status.releaseSavepoint(savepoint); + assertThat(synch.savepointRollbackCalled).isFalse(); } }); @@ -1652,7 +1675,7 @@ void testExistingTransactionWithManualSavepointAndRollback() throws Exception { given(con.getMetaData()).willReturn(md); given(con.setSavepoint("SAVEPOINT_1")).willReturn(sp); - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); @@ -1664,8 +1687,13 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run assertThat(status.isNewTransaction()).isTrue(); assertThat(status.isNested()).isFalse(); assertThat(status.hasSavepoint()).isFalse(); + TestSavepointSynchronization synch = new TestSavepointSynchronization(); + TransactionSynchronizationManager.registerSynchronization(synch); Object savepoint = status.createSavepoint(); + assertThat(synch.savepointCalled).isTrue(); + assertThat(synch.savepointRollbackCalled).isFalse(); status.rollbackToSavepoint(savepoint); + assertThat(synch.savepointRollbackCalled).isTrue(); } }); @@ -1677,7 +1705,7 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run @Test void testTransactionWithPropagationNested() throws Exception { - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); @@ -1703,7 +1731,7 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run @Test void testTransactionWithPropagationNestedAndRollback() throws Exception { - final TransactionTemplate tt = new TransactionTemplate(tm); + TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); @@ -1805,4 +1833,24 @@ protected void doAfterCompletion(int status) { } } + + private static class TestSavepointSynchronization implements TransactionSynchronization { + + public boolean savepointCalled; + + public boolean savepointRollbackCalled; + + @Override + public void savepoint(Object savepoint) { + assertThat(this.savepointCalled).isFalse(); + this.savepointCalled = true; + } + + @Override + public void savepointRollback(Object savepoint) { + assertThat(this.savepointRollbackCalled).isFalse(); + this.savepointRollbackCalled = true; + } + } + } diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseBuilderTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseBuilderTests.java index 6dbe8d6f89d6..d496f4a59f8f 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseBuilderTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseBuilderTests.java @@ -36,33 +36,32 @@ */ class EmbeddedDatabaseBuilderTests { - private final EmbeddedDatabaseBuilder builder = new EmbeddedDatabaseBuilder(new ClassRelativeResourceLoader( - getClass())); + private final EmbeddedDatabaseBuilder builder = new EmbeddedDatabaseBuilder(new ClassRelativeResourceLoader(getClass())); @Test void addDefaultScripts() { doTwice(() -> { - EmbeddedDatabase db = new EmbeddedDatabaseBuilder()// - .addDefaultScripts()// - .build(); + EmbeddedDatabase db = new EmbeddedDatabaseBuilder() + .addDefaultScripts() + .build(); assertDatabaseCreatedAndShutdown(db); }); } @Test void addScriptWithBogusFileName() { - assertThatExceptionOfType(CannotReadScriptException.class).isThrownBy( - new EmbeddedDatabaseBuilder().addScript("bogus.sql")::build); + assertThatExceptionOfType(CannotReadScriptException.class) + .isThrownBy(new EmbeddedDatabaseBuilder().addScript("bogus.sql")::build); } @Test void addScript() { doTwice(() -> { - EmbeddedDatabase db = builder// - .addScript("db-schema.sql")// - .addScript("db-test-data.sql")// - .build(); + EmbeddedDatabase db = builder + .addScript("db-schema.sql") + .addScript("db-test-data.sql") + .build(); assertDatabaseCreatedAndShutdown(db); }); } @@ -70,9 +69,9 @@ void addScript() { @Test void addScripts() { doTwice(() -> { - EmbeddedDatabase db = builder// - .addScripts("db-schema.sql", "db-test-data.sql")// - .build(); + EmbeddedDatabase db = builder + .addScripts("db-schema.sql", "db-test-data.sql") + .build(); assertDatabaseCreatedAndShutdown(db); }); } @@ -80,9 +79,9 @@ void addScripts() { @Test void addScriptsWithDefaultCommentPrefix() { doTwice(() -> { - EmbeddedDatabase db = builder// - .addScripts("db-schema-comments.sql", "db-test-data.sql")// - .build(); + EmbeddedDatabase db = builder + .addScripts("db-schema-comments.sql", "db-test-data.sql") + .build(); assertDatabaseCreatedAndShutdown(db); }); } @@ -90,10 +89,10 @@ void addScriptsWithDefaultCommentPrefix() { @Test void addScriptsWithCustomCommentPrefix() { doTwice(() -> { - EmbeddedDatabase db = builder// - .addScripts("db-schema-custom-comments.sql", "db-test-data.sql")// - .setCommentPrefix("~")// - .build(); + EmbeddedDatabase db = builder + .addScripts("db-schema-custom-comments.sql", "db-test-data.sql") + .setCommentPrefix("~") + .build(); assertDatabaseCreatedAndShutdown(db); }); } @@ -101,11 +100,11 @@ void addScriptsWithCustomCommentPrefix() { @Test void addScriptsWithCustomBlockComments() { doTwice(() -> { - EmbeddedDatabase db = builder// - .addScripts("db-schema-block-comments.sql", "db-test-data.sql")// - .setBlockCommentStartDelimiter("{*")// - .setBlockCommentEndDelimiter("*}")// - .build(); + EmbeddedDatabase db = builder + .addScripts("db-schema-block-comments.sql", "db-test-data.sql") + .setBlockCommentStartDelimiter("{*") + .setBlockCommentEndDelimiter("*}") + .build(); assertDatabaseCreatedAndShutdown(db); }); } @@ -113,10 +112,27 @@ void addScriptsWithCustomBlockComments() { @Test void setTypeToH2() { doTwice(() -> { - EmbeddedDatabase db = builder// - .setType(H2)// - .addScripts("db-schema.sql", "db-test-data.sql")// - .build(); + EmbeddedDatabase db = builder + .setType(H2) + .addScripts("db-schema.sql", "db-test-data.sql") + .build(); + assertDatabaseCreatedAndShutdown(db); + }); + } + + @Test + void setTypeConfigurerToCustomH2() { + doTwice(() -> { + EmbeddedDatabase db = builder + .setDatabaseConfigurer(EmbeddedDatabaseConfigurers.customizeConfigurer(H2, defaultConfigurer -> + new EmbeddedDatabaseConfigurerDelegate(defaultConfigurer) { + @Override + public void configureConnectionProperties(ConnectionProperties properties, String databaseName) { + super.configureConnectionProperties(properties, databaseName); + } + })) + .addScripts("db-schema.sql", "db-test-data.sql") + .build(); assertDatabaseCreatedAndShutdown(db); }); } @@ -124,18 +140,17 @@ void setTypeToH2() { @Test void setTypeToDerbyAndIgnoreFailedDrops() { doTwice(() -> { - EmbeddedDatabase db = builder// - .setType(DERBY)// - .ignoreFailedDrops(true)// - .addScripts("db-schema-derby-with-drop.sql", "db-test-data.sql").build(); + EmbeddedDatabase db = builder + .setType(DERBY) + .ignoreFailedDrops(true) + .addScripts("db-schema-derby-with-drop.sql", "db-test-data.sql").build(); assertDatabaseCreatedAndShutdown(db); }); } @Test void createSameSchemaTwiceWithoutUniqueDbNames() { - EmbeddedDatabase db1 = new EmbeddedDatabaseBuilder(new ClassRelativeResourceLoader(getClass())) - .addScripts("db-schema-without-dropping.sql").build(); + EmbeddedDatabase db1 = builder.addScripts("db-schema-without-dropping.sql").build(); try { assertThatExceptionOfType(ScriptStatementFailedException.class).isThrownBy(() -> new EmbeddedDatabaseBuilder(new ClassRelativeResourceLoader(getClass())).addScripts("db-schema-without-dropping.sql").build()); @@ -147,20 +162,20 @@ void createSameSchemaTwiceWithoutUniqueDbNames() { @Test void createSameSchemaTwiceWithGeneratedUniqueDbNames() { - EmbeddedDatabase db1 = new EmbeddedDatabaseBuilder(new ClassRelativeResourceLoader(getClass()))// - .addScripts("db-schema-without-dropping.sql", "db-test-data.sql")// - .generateUniqueName(true)// - .build(); + EmbeddedDatabase db1 = builder + .addScripts("db-schema-without-dropping.sql", "db-test-data.sql") + .generateUniqueName(true) + .build(); JdbcTemplate template1 = new JdbcTemplate(db1); assertNumRowsInTestTable(template1, 1); template1.update("insert into T_TEST (NAME) values ('Sam')"); assertNumRowsInTestTable(template1, 2); - EmbeddedDatabase db2 = new EmbeddedDatabaseBuilder(new ClassRelativeResourceLoader(getClass()))// - .addScripts("db-schema-without-dropping.sql", "db-test-data.sql")// - .generateUniqueName(true)// - .build(); + EmbeddedDatabase db2 = new EmbeddedDatabaseBuilder(new ClassRelativeResourceLoader(getClass())) + .addScripts("db-schema-without-dropping.sql", "db-test-data.sql") + .generateUniqueName(true) + .build(); assertDatabaseCreated(db2); db1.shutdown(); diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseFactoryTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseFactoryTests.java index ecb41f3e783d..2486518b9ede 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseFactoryTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/embedded/EmbeddedDatabaseFactoryTests.java @@ -17,6 +17,7 @@ package org.springframework.jdbc.datasource.embedded; import java.sql.Connection; +import java.sql.SQLException; import org.junit.jupiter.api.Test; @@ -25,11 +26,14 @@ import static org.assertj.core.api.Assertions.assertThat; /** + * Tests for {@link EmbeddedDatabaseFactory}. + * * @author Keith Donald + * @author Stephane Nicoll */ class EmbeddedDatabaseFactoryTests { - private EmbeddedDatabaseFactory factory = new EmbeddedDatabaseFactory(); + private final EmbeddedDatabaseFactory factory = new EmbeddedDatabaseFactory(); @Test @@ -41,6 +45,45 @@ void testGetDataSource() { db.shutdown(); } + @Test + void customizeConfigurerWithAnotherDatabaseName() throws SQLException { + this.factory.setDatabaseName("original-db-mame"); + this.factory.setDatabaseConfigurer(EmbeddedDatabaseConfigurers.customizeConfigurer( + EmbeddedDatabaseType.H2, defaultConfigurer -> + new EmbeddedDatabaseConfigurerDelegate(defaultConfigurer) { + @Override + public void configureConnectionProperties(ConnectionProperties properties, String databaseName) { + super.configureConnectionProperties(properties, "custom-db-name"); + } + })); + EmbeddedDatabase db = this.factory.getDatabase(); + try (Connection connection = db.getConnection()) { + assertThat(connection.getMetaData().getURL()).contains("custom-db-name") + .doesNotContain("original-db-mame"); + } + db.shutdown(); + } + + @Test + void customizeConfigurerWithCustomizedUrl() throws SQLException { + this.factory.setDatabaseName("original-db-mame"); + this.factory.setDatabaseConfigurer(EmbeddedDatabaseConfigurers.customizeConfigurer( + EmbeddedDatabaseType.H2, defaultConfigurer -> + new EmbeddedDatabaseConfigurerDelegate(defaultConfigurer) { + @Override + public void configureConnectionProperties(ConnectionProperties properties, String databaseName) { + super.configureConnectionProperties(properties, databaseName); + properties.setUrl("jdbc:h2:mem:custom-db-name;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;MODE=MariaDB"); + } + })); + EmbeddedDatabase db = this.factory.getDatabase(); + try (Connection connection = db.getConnection()) { + assertThat(connection.getMetaData().getURL()).contains("custom-db-name") + .doesNotContain("original-db-mame"); + } + db.shutdown(); + } + private static class StubDatabasePopulator implements DatabasePopulator { diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/object/SqlQueryTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/object/SqlQueryTests.java index ac2d0f3c2533..1a525815aa54 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/object/SqlQueryTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/object/SqlQueryTests.java @@ -55,7 +55,7 @@ */ class SqlQueryTests { - //FIXME inline? + // FIXME inline? private static final String SELECT_ID = "select id from custmr"; private static final String SELECT_ID_WHERE = diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/support/DefaultLobHandlerTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/support/DefaultLobHandlerTests.java index 06a5facbfe3a..82ad7db7e25b 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/support/DefaultLobHandlerTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/support/DefaultLobHandlerTests.java @@ -37,6 +37,7 @@ * @author Juergen Hoeller * @since 17.12.2003 */ +@SuppressWarnings("deprecation") class DefaultLobHandlerTests { private ResultSet rs = mock(); diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/support/incrementer/H2SequenceMaxValueIncrementerTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/support/incrementer/H2SequenceMaxValueIncrementerTests.java index 92318bc2531c..ada94f94e8e8 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/support/incrementer/H2SequenceMaxValueIncrementerTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/support/incrementer/H2SequenceMaxValueIncrementerTests.java @@ -67,7 +67,7 @@ void incrementsSequenceUsingH2EmbeddedDatabaseConfigurer() { * Tests that the incrementer works when using all supported H2 compatibility modes. */ @ParameterizedTest - @EnumSource(ModeEnum.class) + @EnumSource void incrementsSequenceWithExplicitH2CompatibilityMode(ModeEnum mode) { String connectionUrl = String.format("jdbc:h2:mem:%s;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;MODE=%s", UUID.randomUUID(), mode); DataSource dataSource = new SimpleDriverDataSource(new org.h2.Driver(), connectionUrl, "sa", ""); diff --git a/spring-jms/src/main/java/org/springframework/jms/annotation/JmsListener.java b/spring-jms/src/main/java/org/springframework/jms/annotation/JmsListener.java index 6d5470686406..e35bd8de74cf 100644 --- a/spring-jms/src/main/java/org/springframework/jms/annotation/JmsListener.java +++ b/spring-jms/src/main/java/org/springframework/jms/annotation/JmsListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -114,9 +114,9 @@ /** * The name for the durable subscription, if any. - *

    As of Spring Framework 5.3.26, if an explicit subscription name is not - * specified, a default subscription name will be generated based on the fully - * qualified name of the annotated listener method — for example, + *

    If an explicit subscription name is not specified, a default subscription + * name will be generated based on the fully qualified name of the annotated + * listener method — for example, * {@code "org.example.jms.ProductListener.processRequest"} for a * {@code processRequest(...)} listener method in the * {@code org.example.jms.ProductListener} class. 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 1083a32fc58b..39a002e1de74 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 @@ -114,8 +114,8 @@ public String getSelector() { /** * Set a concurrency for the listener, if any. - *

    The concurrency limits can be a "lower-upper" String, e.g. "5-10", or a simple - * upper limit String, e.g. "10" (the lower limit will be 1 in this case). + *

    The concurrency limits can be a "lower-upper" String, for example, "5-10", or a simple + * upper limit String, for example, "10" (the lower limit will be 1 in this case). *

    The underlying container may or may not support all features. For instance, it * may not be able to scale: in that case only the upper value is used. */ diff --git a/spring-jms/src/main/java/org/springframework/jms/config/JmsListenerEndpointRegistrar.java b/spring-jms/src/main/java/org/springframework/jms/config/JmsListenerEndpointRegistrar.java index 3848ba895436..faaf91197318 100644 --- a/spring-jms/src/main/java/org/springframework/jms/config/JmsListenerEndpointRegistrar.java +++ b/spring-jms/src/main/java/org/springframework/jms/config/JmsListenerEndpointRegistrar.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +22,6 @@ import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; import org.springframework.beans.factory.InitializingBean; -import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.lang.Nullable; import org.springframework.messaging.handler.annotation.support.DefaultMessageHandlerMethodFactory; import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory; @@ -57,8 +56,6 @@ public class JmsListenerEndpointRegistrar implements BeanFactoryAware, Initializ private boolean startImmediately; - private Object mutex = this.endpointDescriptors; - /** * Set the {@link JmsListenerEndpointRegistry} instance to use. @@ -124,9 +121,6 @@ public void setContainerFactoryBeanName(String containerFactoryBeanName) { @Override public void setBeanFactory(BeanFactory beanFactory) { this.beanFactory = beanFactory; - if (beanFactory instanceof ConfigurableBeanFactory cbf) { - this.mutex = cbf.getSingletonMutex(); - } } @@ -137,13 +131,11 @@ public void afterPropertiesSet() { protected void registerAllEndpoints() { Assert.state(this.endpointRegistry != null, "No JmsListenerEndpointRegistry set"); - synchronized (this.mutex) { - for (JmsListenerEndpointDescriptor descriptor : this.endpointDescriptors) { - this.endpointRegistry.registerListenerContainer( - descriptor.endpoint, resolveContainerFactory(descriptor)); - } - this.startImmediately = true; // trigger immediate startup + for (JmsListenerEndpointDescriptor descriptor : this.endpointDescriptors) { + this.endpointRegistry.registerListenerContainer( + descriptor.endpoint, resolveContainerFactory(descriptor)); } + this.startImmediately = true; // trigger immediate startup } private JmsListenerContainerFactory resolveContainerFactory(JmsListenerEndpointDescriptor descriptor) { @@ -180,15 +172,13 @@ public void registerEndpoint(JmsListenerEndpoint endpoint, @Nullable JmsListener // Factory may be null, we defer the resolution right before actually creating the container JmsListenerEndpointDescriptor descriptor = new JmsListenerEndpointDescriptor(endpoint, factory); - synchronized (this.mutex) { - if (this.startImmediately) { // register and start immediately - Assert.state(this.endpointRegistry != null, "No JmsListenerEndpointRegistry set"); - this.endpointRegistry.registerListenerContainer(descriptor.endpoint, - resolveContainerFactory(descriptor), true); - } - else { - this.endpointDescriptors.add(descriptor); - } + if (this.startImmediately) { // register and start immediately + Assert.state(this.endpointRegistry != null, "No JmsListenerEndpointRegistry set"); + this.endpointRegistry.registerListenerContainer(descriptor.endpoint, + resolveContainerFactory(descriptor), true); + } + else { + this.endpointDescriptors.add(descriptor); } } diff --git a/spring-jms/src/main/java/org/springframework/jms/connection/ConnectionFactoryUtils.java b/spring-jms/src/main/java/org/springframework/jms/connection/ConnectionFactoryUtils.java index 03244b01ff17..f92537092def 100644 --- a/spring-jms/src/main/java/org/springframework/jms/connection/ConnectionFactoryUtils.java +++ b/spring-jms/src/main/java/org/springframework/jms/connection/ConnectionFactoryUtils.java @@ -285,6 +285,7 @@ public static Session doGetTransactionalSession( * @throws JMSException in case of JMS failure */ @Nullable + @SuppressWarnings("NullAway") public static Session doGetTransactionalSession( ConnectionFactory connectionFactory, ResourceFactory resourceFactory, boolean startConnection) throws JMSException { @@ -412,7 +413,7 @@ public interface ResourceFactory { /** * Callback for resource cleanup at the end of a non-native JMS transaction - * (e.g. when participating in a JtaTransactionManager transaction). + * (for example, when participating in a JtaTransactionManager transaction). * @see org.springframework.transaction.jta.JtaTransactionManager */ private static class JmsResourceSynchronization extends ResourceHolderSynchronization { diff --git a/spring-jms/src/main/java/org/springframework/jms/connection/DelegatingConnectionFactory.java b/spring-jms/src/main/java/org/springframework/jms/connection/DelegatingConnectionFactory.java index 7a78b1786512..3dcbaf071b73 100644 --- a/spring-jms/src/main/java/org/springframework/jms/connection/DelegatingConnectionFactory.java +++ b/spring-jms/src/main/java/org/springframework/jms/connection/DelegatingConnectionFactory.java @@ -33,7 +33,7 @@ * {@link jakarta.jms.ConnectionFactory} implementation that delegates all calls * to a given target {@link jakarta.jms.ConnectionFactory}, adapting specific * {@code create(Queue/Topic)Connection} calls to the target ConnectionFactory - * if necessary (e.g. when running JMS 1.0.2 API based code against a generic + * if necessary (for example, when running JMS 1.0.2 API based code against a generic * JMS 1.1 ConnectionFactory, such as ActiveMQ's PooledConnectionFactory). * *

    As of Spring Framework 5, this class supports JMS 2.0 {@code JMSContext} diff --git a/spring-jms/src/main/java/org/springframework/jms/connection/JmsResourceHolder.java b/spring-jms/src/main/java/org/springframework/jms/connection/JmsResourceHolder.java index 7b43c35152c0..c559d09c9beb 100644 --- a/spring-jms/src/main/java/org/springframework/jms/connection/JmsResourceHolder.java +++ b/spring-jms/src/main/java/org/springframework/jms/connection/JmsResourceHolder.java @@ -222,6 +222,7 @@ public S getSession(Class sessionType) { * for the given connection, or {@code null} if none. */ @Nullable + @SuppressWarnings("NullAway") public S getSession(Class sessionType, @Nullable Connection connection) { Deque sessions = (connection != null ? this.sessionsPerConnection.get(connection) : this.sessions); @@ -250,7 +251,7 @@ public void commitAll() throws JMSException { while (ds != null) { if (TransactionSynchronizationManager.hasResource(ds)) { // IllegalStateException from sharing the underlying JDBC Connection - // which typically gets committed first, e.g. with Oracle AQ --> ignore + // which typically gets committed first, for example, with Oracle AQ --> ignore return; } try { diff --git a/spring-jms/src/main/java/org/springframework/jms/connection/SingleConnectionFactory.java b/spring-jms/src/main/java/org/springframework/jms/connection/SingleConnectionFactory.java index bdaeee82ef6a..186fcaa3baab 100644 --- a/spring-jms/src/main/java/org/springframework/jms/connection/SingleConnectionFactory.java +++ b/spring-jms/src/main/java/org/springframework/jms/connection/SingleConnectionFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import jakarta.jms.Connection; import jakarta.jms.ConnectionFactory; @@ -50,7 +52,7 @@ * A JMS ConnectionFactory adapter that returns the same Connection * from all {@link #createConnection()} calls, and ignores calls to * {@link jakarta.jms.Connection#close()}. According to the JMS Connection - * model, this is perfectly thread-safe (in contrast to e.g. JDBC). The + * model, this is perfectly thread-safe (in contrast to, for example, JDBC). The * shared Connection can be automatically recovered in case of an Exception. * *

    You can either pass in a specific JMS Connection directly or let this @@ -116,8 +118,8 @@ public class SingleConnectionFactory implements ConnectionFactory, QueueConnecti /** Whether the shared Connection has been started. */ private int startedCount = 0; - /** Synchronization monitor for the shared Connection. */ - private final Object connectionMonitor = new Object(); + /** Lifecycle lock for the shared Connection. */ + private final Lock connectionLock = new ReentrantLock(); /** @@ -252,10 +254,14 @@ public Connection createConnection(String username, String password) throws JMSE @Override public QueueConnection createQueueConnection() throws JMSException { Connection con; - synchronized (this.connectionMonitor) { + this.connectionLock.lock(); + try { this.pubSubMode = Boolean.FALSE; con = createConnection(); } + finally { + this.connectionLock.unlock(); + } if (!(con instanceof QueueConnection queueConnection)) { throw new jakarta.jms.IllegalStateException( "This SingleConnectionFactory does not hold a QueueConnection but rather: " + con); @@ -272,10 +278,14 @@ public QueueConnection createQueueConnection(String username, String password) t @Override public TopicConnection createTopicConnection() throws JMSException { Connection con; - synchronized (this.connectionMonitor) { + this.connectionLock.lock(); + try { this.pubSubMode = Boolean.TRUE; con = createConnection(); } + finally { + this.connectionLock.unlock(); + } if (!(con instanceof TopicConnection topicConnection)) { throw new jakarta.jms.IllegalStateException( "This SingleConnectionFactory does not hold a TopicConnection but rather: " + con); @@ -322,13 +332,18 @@ private ConnectionFactory obtainTargetConnectionFactory() { * @throws jakarta.jms.JMSException if thrown by JMS API methods * @see #initConnection() */ + @SuppressWarnings("NullAway") protected Connection getConnection() throws JMSException { - synchronized (this.connectionMonitor) { + this.connectionLock.lock(); + try { if (this.connection == null) { initConnection(); } return this.connection; } + finally { + this.connectionLock.unlock(); + } } /** @@ -386,9 +401,13 @@ public void stop() { */ @Override public boolean isRunning() { - synchronized (this.connectionMonitor) { + this.connectionLock.lock(); + try { return (this.connection != null); } + finally { + this.connectionLock.unlock(); + } } @@ -404,7 +423,8 @@ public void initConnection() throws JMSException { throw new IllegalStateException( "'targetConnectionFactory' is required for lazily initializing a Connection"); } - synchronized (this.connectionMonitor) { + this.connectionLock.lock(); + try { if (this.connection != null) { closeConnection(this.connection); } @@ -433,6 +453,9 @@ public void initConnection() throws JMSException { logger.debug("Established shared JMS Connection: " + this.connection); } } + finally { + this.connectionLock.unlock(); + } } /** @@ -531,12 +554,16 @@ else if (Boolean.TRUE.equals(this.pubSubMode) && con instanceof TopicConnection * @see #closeConnection */ public void resetConnection() { - synchronized (this.connectionMonitor) { + this.connectionLock.lock(); + try { if (this.connection != null) { closeConnection(this.connection); } this.connection = null; } + finally { + this.connectionLock.unlock(); + } } /** @@ -548,14 +575,11 @@ protected void closeConnection(Connection con) { logger.debug("Closing shared JMS Connection: " + con); } try { - try { + try (con) { if (this.startedCount > 0) { con.stop(); } } - finally { - con.close(); - } } catch (jakarta.jms.IllegalStateException ex) { logger.debug("Ignoring Connection state exception - assuming already closed: " + ex); @@ -634,7 +658,8 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl } case "setExceptionListener" -> { // Handle setExceptionListener method: add to the chain. - synchronized (connectionMonitor) { + connectionLock.lock(); + try { if (aggregatedExceptionListener != null) { ExceptionListener listener = (ExceptionListener) args[0]; if (listener != this.localExceptionListener) { @@ -656,9 +681,13 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl "which will allow for registering further ExceptionListeners to the recovery chain."); } } + finally { + connectionLock.unlock(); + } } case "getExceptionListener" -> { - synchronized (connectionMonitor) { + connectionLock.lock(); + try { if (this.localExceptionListener != null) { return this.localExceptionListener; } @@ -666,6 +695,9 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl return getExceptionListener(); } } + finally { + connectionLock.unlock(); + } } case "start" -> { localStart(); @@ -677,7 +709,8 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl } case "close" -> { localStop(); - synchronized (connectionMonitor) { + connectionLock.lock(); + try { if (this.localExceptionListener != null) { if (aggregatedExceptionListener != null) { aggregatedExceptionListener.delegates.remove(this.localExceptionListener); @@ -685,6 +718,9 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl this.localExceptionListener = null; } } + finally { + connectionLock.unlock(); + } return null; } case "createSession", "createQueueSession", "createTopicSession" -> { @@ -727,7 +763,8 @@ else if (args.length == 2) { } private void localStart() throws JMSException { - synchronized (connectionMonitor) { + connectionLock.lock(); + try { if (!this.locallyStarted) { this.locallyStarted = true; if (startedCount == 0 && connection != null) { @@ -736,10 +773,14 @@ private void localStart() throws JMSException { startedCount++; } } + finally { + connectionLock.unlock(); + } } private void localStop() throws JMSException { - synchronized (connectionMonitor) { + connectionLock.lock(); + try { if (this.locallyStarted) { this.locallyStarted = false; if (startedCount == 1 && connection != null) { @@ -750,6 +791,9 @@ private void localStop() throws JMSException { } } } + finally { + connectionLock.unlock(); + } } private SingleConnectionFactory factory() { @@ -771,9 +815,13 @@ public void onException(JMSException ex) { // Iterate over temporary copy in order to avoid ConcurrentModificationException, // since listener invocations may in turn trigger registration of listeners... Set copy; - synchronized (connectionMonitor) { + connectionLock.lock(); + try { copy = new LinkedHashSet<>(this.delegates); } + finally { + connectionLock.unlock(); + } for (ExceptionListener listener : copy) { listener.onException(ex); } diff --git a/spring-jms/src/main/java/org/springframework/jms/connection/TransactionAwareConnectionFactoryProxy.java b/spring-jms/src/main/java/org/springframework/jms/connection/TransactionAwareConnectionFactoryProxy.java index ad903157548f..e5ffb4b0673a 100644 --- a/spring-jms/src/main/java/org/springframework/jms/connection/TransactionAwareConnectionFactoryProxy.java +++ b/spring-jms/src/main/java/org/springframework/jms/connection/TransactionAwareConnectionFactoryProxy.java @@ -70,7 +70,7 @@ *

    Returned transactional Session proxies will implement the {@link SessionProxy} * interface to allow for access to the underlying target Session. This is only * intended for accessing vendor-specific Session API or for testing purposes - * (e.g. to perform manual transaction control). For typical application purposes, + * (for example, to perform manual transaction control). For typical application purposes, * simply use the standard JMS Session interface. * *

    As of Spring Framework 5, this class delegates JMS 2.0 {@code JMSContext} diff --git a/spring-jms/src/main/java/org/springframework/jms/connection/UserCredentialsConnectionFactoryAdapter.java b/spring-jms/src/main/java/org/springframework/jms/connection/UserCredentialsConnectionFactoryAdapter.java index ab2b6369422c..5d5868e8b9cd 100644 --- a/spring-jms/src/main/java/org/springframework/jms/connection/UserCredentialsConnectionFactoryAdapter.java +++ b/spring-jms/src/main/java/org/springframework/jms/connection/UserCredentialsConnectionFactoryAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,14 +33,16 @@ /** * An adapter for a target JMS {@link jakarta.jms.ConnectionFactory}, applying the - * given user credentials to every standard {@code createConnection()} call, - * that is, implicitly invoking {@code createConnection(username, password)} - * on the target. All other methods simply delegate to the corresponding methods - * of the target ConnectionFactory. + * given user credentials to every standard methods that can also be used with + * authentication, this {@code createConnection()} and {@code createContext()}. In + * other words, it is implicitly invoking {@code createConnection(username, password)} or + * {@code createContext(username, password)}} on the target. All other methods simply + * delegate to the corresponding methods of the target ConnectionFactory. * *

    Can be used to proxy a target JNDI ConnectionFactory that does not have user * credentials configured. Client code can work with the ConnectionFactory without - * passing in username and password on every {@code createConnection()} call. + * passing in username and password on every {@code createConnection()} and + * {@code createContext()} call. * *

    In the following example, client code can simply transparently work * with the preconfigured "myConnectionFactory", implicitly accessing @@ -58,9 +60,9 @@ * </bean> * *

    If the "username" is empty, this proxy will simply delegate to the standard - * {@code createConnection()} method of the target ConnectionFactory. - * This can be used to keep a UserCredentialsConnectionFactoryAdapter bean - * definition just for the option of implicitly passing in user credentials + * {@code createConnection()} or {@code createContext()} method of the target + * ConnectionFactory. This can be used to keep a UserCredentialsConnectionFactoryAdapter + * bean definition just for the option of implicitly passing in user credentials * if the particular target ConnectionFactory requires it. * *

    As of Spring Framework 5, this class delegates JMS 2.0 {@code JMSContext} @@ -69,8 +71,10 @@ * as long as no actual JMS 2.0 calls are triggered by the application's setup. * * @author Juergen Hoeller + * @author Stephane Nicoll * @since 1.2 * @see #createConnection + * @see #createContext * @see #createQueueConnection * @see #createTopicConnection */ @@ -296,7 +300,22 @@ protected TopicConnection doCreateTopicConnection( @Override public JMSContext createContext() { - return obtainTargetConnectionFactory().createContext(); + JmsUserCredentials threadCredentials = this.threadBoundCredentials.get(); + if (threadCredentials != null) { + return doCreateContext(threadCredentials.username, threadCredentials.password); + } + else { + return doCreateContext(this.username, this.password); + } + } + + protected JMSContext doCreateContext(@Nullable String username, @Nullable String password) { + if (StringUtils.hasLength(username)) { + return obtainTargetConnectionFactory().createContext(username, password); + } + else { + return obtainTargetConnectionFactory().createContext(); + } } @Override @@ -311,7 +330,22 @@ public JMSContext createContext(String userName, String password, int sessionMod @Override public JMSContext createContext(int sessionMode) { - return obtainTargetConnectionFactory().createContext(sessionMode); + JmsUserCredentials threadCredentials = this.threadBoundCredentials.get(); + if (threadCredentials != null) { + return doCreateContext(threadCredentials.username, threadCredentials.password, sessionMode); + } + else { + return doCreateContext(this.username, this.password, sessionMode); + } + } + + protected JMSContext doCreateContext(@Nullable String username, @Nullable String password, int sessionMode) { + if (StringUtils.hasLength(username)) { + return obtainTargetConnectionFactory().createContext(username, password, sessionMode); + } + else { + return obtainTargetConnectionFactory().createContext(sessionMode); + } } private ConnectionFactory obtainTargetConnectionFactory() { diff --git a/spring-jms/src/main/java/org/springframework/jms/listener/AbstractJmsListeningContainer.java b/spring-jms/src/main/java/org/springframework/jms/listener/AbstractJmsListeningContainer.java index 058a508f4cd8..8ed0f74fe252 100644 --- a/spring-jms/src/main/java/org/springframework/jms/listener/AbstractJmsListeningContainer.java +++ b/spring-jms/src/main/java/org/springframework/jms/listener/AbstractJmsListeningContainer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,9 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import jakarta.jms.Connection; import jakarta.jms.JMSException; @@ -77,7 +80,7 @@ public abstract class AbstractJmsListeningContainer extends JmsDestinationAccess private boolean sharedConnectionStarted = false; - protected final Object sharedConnectionMonitor = new Object(); + protected final Lock sharedConnectionLock = new ReentrantLock(); private boolean active = false; @@ -85,7 +88,9 @@ public abstract class AbstractJmsListeningContainer extends JmsDestinationAccess private final List pausedTasks = new ArrayList<>(); - protected final Object lifecycleMonitor = new Object(); + protected final Lock lifecycleLock = new ReentrantLock(); + + protected final Condition lifecycleCondition = this.lifecycleLock.newCondition(); /** @@ -199,9 +204,13 @@ public void destroy() { */ public void initialize() throws JmsException { try { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { this.active = true; - this.lifecycleMonitor.notifyAll(); + this.lifecycleCondition.signalAll(); + } + finally { + this.lifecycleLock.unlock(); } doInitialize(); } @@ -218,13 +227,18 @@ public void initialize() throws JmsException { */ public void shutdown() throws JmsException { logger.debug("Shutting down JMS listener container"); + boolean wasRunning; - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { wasRunning = this.running; this.running = false; this.active = false; this.pausedTasks.clear(); - this.lifecycleMonitor.notifyAll(); + this.lifecycleCondition.signalAll(); + } + finally { + this.lifecycleLock.unlock(); } // Stop shared Connection early, if necessary. @@ -256,9 +270,13 @@ public void shutdown() throws JmsException { * that is, whether it has been set up but not shut down yet. */ public final boolean isActive() { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { return this.active; } + finally { + this.lifecycleLock.unlock(); + } } /** @@ -288,11 +306,15 @@ protected void doStart() throws JMSException { } // Reschedule paused tasks, if any. - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { this.running = true; - this.lifecycleMonitor.notifyAll(); + this.lifecycleCondition.signalAll(); resumePausedTasks(); } + finally { + this.lifecycleLock.unlock(); + } // Start the shared Connection, if any. if (sharedConnectionEnabled()) { @@ -321,9 +343,13 @@ public void stop() throws JmsException { * @see #stopSharedConnection */ protected void doStop() throws JMSException { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { this.running = false; - this.lifecycleMonitor.notifyAll(); + this.lifecycleCondition.signalAll(); + } + finally { + this.lifecycleLock.unlock(); } if (sharedConnectionEnabled()) { @@ -370,12 +396,16 @@ protected boolean runningAllowed() { * @throws JMSException if thrown by JMS API methods */ protected void establishSharedConnection() throws JMSException { - synchronized (this.sharedConnectionMonitor) { + this.sharedConnectionLock.lock(); + try { if (this.sharedConnection == null) { this.sharedConnection = createSharedConnection(); logger.debug("Established shared JMS Connection"); } } + finally { + this.sharedConnectionLock.unlock(); + } } /** @@ -385,13 +415,17 @@ protected void establishSharedConnection() throws JMSException { * @throws JMSException if thrown by JMS API methods */ protected final void refreshSharedConnection() throws JMSException { - synchronized (this.sharedConnectionMonitor) { + this.sharedConnectionLock.lock(); + try { releaseSharedConnection(); this.sharedConnection = createSharedConnection(); if (this.sharedConnectionStarted) { this.sharedConnection.start(); } } + finally { + this.sharedConnectionLock.unlock(); + } } /** @@ -435,7 +469,8 @@ protected void prepareSharedConnection(Connection connection) throws JMSExceptio * @see jakarta.jms.Connection#start() */ protected void startSharedConnection() throws JMSException { - synchronized (this.sharedConnectionMonitor) { + this.sharedConnectionLock.lock(); + try { this.sharedConnectionStarted = true; if (this.sharedConnection != null) { try { @@ -446,6 +481,9 @@ protected void startSharedConnection() throws JMSException { } } } + finally { + this.sharedConnectionLock.unlock(); + } } /** @@ -454,7 +492,8 @@ protected void startSharedConnection() throws JMSException { * @see jakarta.jms.Connection#start() */ protected void stopSharedConnection() throws JMSException { - synchronized (this.sharedConnectionMonitor) { + this.sharedConnectionLock.lock(); + try { this.sharedConnectionStarted = false; if (this.sharedConnection != null) { try { @@ -465,6 +504,9 @@ protected void stopSharedConnection() throws JMSException { } } } + finally { + this.sharedConnectionLock.unlock(); + } } /** @@ -473,11 +515,15 @@ protected void stopSharedConnection() throws JMSException { * @see ConnectionFactoryUtils#releaseConnection */ protected final void releaseSharedConnection() { - synchronized (this.sharedConnectionMonitor) { + this.sharedConnectionLock.lock(); + try { ConnectionFactoryUtils.releaseConnection( this.sharedConnection, getConnectionFactory(), this.sharedConnectionStarted); this.sharedConnection = null; } + finally { + this.sharedConnectionLock.unlock(); + } } /** @@ -493,13 +539,17 @@ protected final Connection getSharedConnection() { throw new IllegalStateException( "This listener container does not maintain a shared Connection"); } - synchronized (this.sharedConnectionMonitor) { + this.sharedConnectionLock.lock(); + try { if (this.sharedConnection == null) { throw new SharedConnectionNotInitializedException( "This listener container's shared Connection has not been initialized yet"); } return this.sharedConnection; } + finally { + this.sharedConnectionLock.unlock(); + } } @@ -543,7 +593,8 @@ else if (this.active) { * Tasks for which rescheduling failed simply remain in paused mode. */ protected void resumePausedTasks() { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { if (!this.pausedTasks.isEmpty()) { for (Iterator it = this.pausedTasks.iterator(); it.hasNext();) { Object task = it.next(); @@ -561,15 +612,22 @@ protected void resumePausedTasks() { } } } + finally { + this.lifecycleLock.unlock(); + } } /** * Determine the number of currently paused tasks, if any. */ public int getPausedTaskCount() { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { return this.pausedTasks.size(); } + finally { + this.lifecycleLock.unlock(); + } } /** diff --git a/spring-jms/src/main/java/org/springframework/jms/listener/AbstractMessageListenerContainer.java b/spring-jms/src/main/java/org/springframework/jms/listener/AbstractMessageListenerContainer.java index 035805fa8213..f803fc3aa11e 100644 --- a/spring-jms/src/main/java/org/springframework/jms/listener/AbstractMessageListenerContainer.java +++ b/spring-jms/src/main/java/org/springframework/jms/listener/AbstractMessageListenerContainer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -102,7 +102,7 @@ * (i.e. after your business logic executed but before the JMS part got committed), * so duplicate message detection is just there to cover a corner case. *
  • Or wrap your entire processing with an XA transaction, covering the - * reception of the JMS message as well as the execution of the business logic in + * receipt of the JMS message as well as the execution of the business logic in * your message listener (including database operations etc). This is only * supported by {@link DefaultMessageListenerContainer}, through specifying * an external "transactionManager" (typically a @@ -205,7 +205,7 @@ public abstract class AbstractMessageListenerContainer extends AbstractJmsListen *

    Alternatively, specify a "destinationName", to be dynamically * resolved via the {@link org.springframework.jms.support.destination.DestinationResolver}. *

    Note: The destination may be replaced at runtime, with the listener - * container picking up the new destination immediately (works e.g. with + * container picking up the new destination immediately (works, for example, with * DefaultMessageListenerContainer, as long as the cache level is less than * CACHE_CONSUMER). However, this is considered advanced usage; use it with care! * @see #setDestinationName(String) @@ -234,7 +234,7 @@ public Destination getDestination() { * {@link #setDestinationResolver destination resolver}. *

    Alternatively, specify a JMS {@link Destination} object as "destination". *

    Note: The destination may be replaced at runtime, with the listener - * container picking up the new destination immediately (works e.g. with + * container picking up the new destination immediately (works, for example, with * DefaultMessageListenerContainer, as long as the cache level is less than * CACHE_CONSUMER). However, this is considered advanced usage; use it with care! * @see #setDestination(jakarta.jms.Destination) @@ -268,7 +268,7 @@ protected String getDestinationDescription() { * Default is none. *

    See the JMS specification for a detailed definition of selector expressions. *

    Note: The message selector may be replaced at runtime, with the listener - * container picking up the new selector value immediately (works e.g. with + * container picking up the new selector value immediately (works, for example, with * DefaultMessageListenerContainer, as long as the cache level is less than * CACHE_CONSUMER). However, this is considered advanced usage; use it with care! */ @@ -290,7 +290,7 @@ public String getMessageSelector() { * This can be either a standard JMS {@link MessageListener} object * or a Spring {@link SessionAwareMessageListener} object. *

    Note: The message listener may be replaced at runtime, with the listener - * container picking up the new listener object immediately (works e.g. with + * container picking up the new listener object immediately (works, for example, with * DefaultMessageListenerContainer, as long as the cache level is less than * CACHE_CONSUMER). However, this is considered advanced usage; use it with care! * @throws IllegalArgumentException if the supplied listener is not a diff --git a/spring-jms/src/main/java/org/springframework/jms/listener/AbstractPollingMessageListenerContainer.java b/spring-jms/src/main/java/org/springframework/jms/listener/AbstractPollingMessageListenerContainer.java index 28bc209b1ad6..71c4ad360de3 100644 --- a/spring-jms/src/main/java/org/springframework/jms/listener/AbstractPollingMessageListenerContainer.java +++ b/spring-jms/src/main/java/org/springframework/jms/listener/AbstractPollingMessageListenerContainer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +45,7 @@ * *

    This listener container variant is built for repeated polling attempts, * each invoking the {@link #receiveAndExecute} method. The MessageConsumer used - * may be reobtained fo reach attempt or cached in between attempts; this is up + * may be reobtained for each attempt or cached in between attempts; this is up * to the concrete implementation. The receive timeout for each attempt can be * configured through the {@link #setReceiveTimeout "receiveTimeout"} property. * @@ -56,7 +56,7 @@ * full control over the listening process, allowing for custom scaling and throttling * and of concurrent message processing (which is up to concrete subclasses). * - *

    Message reception and listener execution can automatically be wrapped + *

    Message receipt and listener execution can automatically be wrapped * in transactions through passing a Spring * {@link org.springframework.transaction.PlatformTransactionManager} into the * {@link #setTransactionManager "transactionManager"} property. This will usually @@ -105,7 +105,7 @@ public void setSessionTransacted(boolean sessionTransacted) { /** * Specify the Spring {@link org.springframework.transaction.PlatformTransactionManager} - * to use for transactional wrapping of message reception plus listener execution. + * to use for transactional wrapping of message receipt plus listener execution. *

    Default is none, not performing any transactional wrapping. * If specified, this will usually be a Spring * {@link org.springframework.transaction.jta.JtaTransactionManager} or one @@ -115,7 +115,7 @@ public void setSessionTransacted(boolean sessionTransacted) { * Simply switch the {@link #setSessionTransacted "sessionTransacted"} flag * to "true" in order to use a locally transacted JMS Session for the entire * receive processing, including any Session operations performed by a - * {@link SessionAwareMessageListener} (e.g. sending a response message). This + * {@link SessionAwareMessageListener} (for example, sending a response message). This * allows for fully synchronized Spring transactions based on local JMS * transactions, similar to what * {@link org.springframework.jms.connection.JmsTransactionManager} provides. Check @@ -131,7 +131,7 @@ public void setTransactionManager(@Nullable PlatformTransactionManager transacti /** * Return the Spring PlatformTransactionManager to use for transactional - * wrapping of message reception plus listener execution. + * wrapping of message receipt plus listener execution. */ @Nullable protected final PlatformTransactionManager getTransactionManager() { @@ -259,7 +259,7 @@ protected boolean receiveAndExecute( catch (RuntimeException ex) { // Typically a late persistence exception from a listener-used resource // -> handle it as listener exception, not as an infrastructure problem. - // E.g. a database locking failure should not lead to listener shutdown. + // For example, a database locking failure should not lead to listener shutdown. handleListenerException(ex); } return messageReceived; 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 c42477512435..91975cb88b71 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 @@ -20,6 +20,9 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import jakarta.jms.Connection; import jakarta.jms.JMSException; @@ -44,7 +47,7 @@ /** * Message listener container variant that uses plain JMS client APIs, specifically * a loop of {@code MessageConsumer.receive()} calls that also allow for - * transactional reception of messages (registering them with XA transactions). + * transactional receipt of messages (registering them with XA transactions). * Designed to work in a native JMS environment as well as in a Jakarta EE environment, * with only minimal differences in configuration. * @@ -67,7 +70,7 @@ * {@code MessageConsumer} (only refreshed in case of failure), using the JMS provider's * resources as efficiently as possible. * - *

    Message reception and listener execution can automatically be wrapped + *

    Message receipt and listener execution can automatically be wrapped * in transactions by passing a Spring * {@link org.springframework.transaction.PlatformTransactionManager} into the * {@link #setTransactionManager "transactionManager"} property. This will usually @@ -86,7 +89,7 @@ * by specifying a {@link #setMaxConcurrentConsumers "maxConcurrentConsumers"} * value that is higher than the {@link #setConcurrentConsumers "concurrentConsumers"} * value. Since the latter's default is 1, you can also simply specify a - * "maxConcurrentConsumers" of e.g. 5, which will lead to dynamic scaling up to + * "maxConcurrentConsumers" of, for example, 5, which will lead to dynamic scaling up to * 5 concurrent consumers in case of increasing message load, as well as dynamic * shrinking back to the standard number of consumers once the load decreases. * Consider adapting the {@link #setIdleTaskExecutionLimit "idleTaskExecutionLimit"} @@ -190,6 +193,8 @@ public class DefaultMessageListenerContainer extends AbstractPollingMessageListe @Nullable private Executor taskExecutor; + private boolean virtualThreads = false; + private BackOff backOff = new FixedBackOff(DEFAULT_RECOVERY_INTERVAL, Long.MAX_VALUE); private int cacheLevel = CACHE_AUTO; @@ -221,7 +226,7 @@ public class DefaultMessageListenerContainer extends AbstractPollingMessageListe private Object currentRecoveryMarker = new Object(); - private final Object recoveryMonitor = new Object(); + private final Lock recoveryLock = new ReentrantLock(); /** @@ -234,6 +239,12 @@ public class DefaultMessageListenerContainer extends AbstractPollingMessageListe * managed in a specific fashion, for example within a Jakarta EE environment. * A plain thread pool does not add much value, as this listener container * will occupy a number of threads for its entire lifetime. + *

    If the specified executor is a {@link SchedulingTaskExecutor} indicating + * {@link SchedulingTaskExecutor#prefersShortLivedTasks() a preference for + * short-lived tasks}, a {@link #setMaxMessagesPerTask} default of 10 will be + * applied in order to provide dynamic scaling at runtime. With the default + * task executor or a similarly non-pooling external executor specified, + * a {@link #setIdleReceivesPerTaskLimit} default of 10 will apply instead. * @see #setConcurrentConsumers * @see org.springframework.core.task.SimpleAsyncTaskExecutor */ @@ -241,6 +252,29 @@ public void setTaskExecutor(Executor taskExecutor) { this.taskExecutor = taskExecutor; } + /** + * Specify whether the default {@link SimpleAsyncTaskExecutor} should be + * configured to use virtual threads instead of platform threads, for + * efficient blocking behavior in listener threads on Java 21 or higher. + * This is off by default, setting up one platform thread per consumer. + *

    Only applicable if the internal default executor is in use rather than + * an externally provided {@link #setTaskExecutor TaskExecutor} instance. + * The thread name prefix for virtual threads will be derived from the + * listener container's bean name, just like with default platform threads. + *

    Alternatively, pass in a virtual threads based executor through + * {@link #setTaskExecutor} (with externally defined thread naming). + *

    Consider specifying concurrency limits through {@link #setConcurrency} + * or {@link #setConcurrentConsumers}/{@link #setMaxConcurrentConsumers}, + * for potential dynamic scaling. This works fine with the default executor; + * see {@link #setIdleReceivesPerTaskLimit} with its effective default of 10. + * @since 6.2 + * @see #setTaskExecutor + * @see SimpleAsyncTaskExecutor#setVirtualThreads + */ + public void setVirtualThreads(boolean virtualThreads) { + this.virtualThreads = virtualThreads; + } + /** * Specify the {@link BackOff} instance to use to compute the interval * between recovery attempts. If the {@link BackOffExecution} implementation @@ -319,8 +353,8 @@ public int getCacheLevel() { /** - * Specify concurrency limits via a "lower-upper" String, e.g. "5-10", or a simple - * upper limit String, e.g. "10" (the lower limit will be 1 in this case). + * Specify concurrency limits via a "lower-upper" String, for example, "5-10", or a simple + * upper limit String, for example, "10" (the lower limit will be 1 in this case). *

    This listener container will always hold on to the minimum number of consumers * ({@link #setConcurrentConsumers}) and will slowly scale up to the maximum number * of consumers {@link #setMaxConcurrentConsumers} in case of increasing load. @@ -340,12 +374,12 @@ public void setConcurrency(String concurrency) { } catch (NumberFormatException ex) { throw new IllegalArgumentException("Invalid concurrency value [" + concurrency + "]: only " + - "single maximum integer (e.g. \"5\") and minimum-maximum combo (e.g. \"3-5\") supported."); + "single maximum integer (for example, \"5\") and minimum-maximum combo (for example, \"3-5\") supported."); } } /** - * Specify the number of concurrent consumers to create. Default is 1. + * Specify the number of core concurrent consumers to create. Default is 1. *

    Specifying a higher value for this setting will increase the standard * level of scheduled concurrent consumers at runtime: This is effectively * the minimum number of concurrent consumers which will be scheduled @@ -364,12 +398,16 @@ public void setConcurrency(String concurrency) { */ public void setConcurrentConsumers(int concurrentConsumers) { Assert.isTrue(concurrentConsumers > 0, "'concurrentConsumers' value must be at least 1 (one)"); - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { this.concurrentConsumers = concurrentConsumers; if (this.maxConcurrentConsumers < concurrentConsumers) { this.maxConcurrentConsumers = concurrentConsumers; } } + finally { + this.lifecycleLock.unlock(); + } } /** @@ -380,15 +418,19 @@ public void setConcurrentConsumers(int concurrentConsumers) { * @see #getActiveConsumerCount() */ public final int getConcurrentConsumers() { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { return this.concurrentConsumers; } + finally { + this.lifecycleLock.unlock(); + } } /** * Specify the maximum number of concurrent consumers to create. Default is 1. *

    If this setting is higher than "concurrentConsumers", the listener container - * will dynamically schedule new consumers at runtime, provided that enough + * will dynamically schedule surplus consumers at runtime, provided that enough * incoming messages are encountered. Once the load goes down again, the number of * consumers will be reduced to the standard level ("concurrentConsumers") again. *

    Raising the number of concurrent consumers is recommendable in order @@ -404,9 +446,13 @@ public final int getConcurrentConsumers() { */ public void setMaxConcurrentConsumers(int maxConcurrentConsumers) { Assert.isTrue(maxConcurrentConsumers > 0, "'maxConcurrentConsumers' value must be at least 1 (one)"); - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { this.maxConcurrentConsumers = Math.max(maxConcurrentConsumers, this.concurrentConsumers); } + finally { + this.lifecycleLock.unlock(); + } } /** @@ -417,14 +463,18 @@ public void setMaxConcurrentConsumers(int maxConcurrentConsumers) { * @see #getActiveConsumerCount() */ public final int getMaxConcurrentConsumers() { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { return this.maxConcurrentConsumers; } + finally { + this.lifecycleLock.unlock(); + } } /** * Specify the maximum number of messages to process in one task. - * More concretely, this limits the number of message reception attempts + * More concretely, this limits the number of message receipt attempts * per task, which includes receive iterations that did not actually * pick up a message until they hit their timeout (see the * {@link #setReceiveTimeout "receiveTimeout"} property). @@ -446,18 +496,26 @@ public final int getMaxConcurrentConsumers() { */ public void setMaxMessagesPerTask(int maxMessagesPerTask) { Assert.isTrue(maxMessagesPerTask != 0, "'maxMessagesPerTask' must not be 0"); - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { this.maxMessagesPerTask = maxMessagesPerTask; } + finally { + this.lifecycleLock.unlock(); + } } /** * Return the maximum number of messages to process in one task. */ public final int getMaxMessagesPerTask() { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { return this.maxMessagesPerTask; } + finally { + this.lifecycleLock.unlock(); + } } /** @@ -472,18 +530,26 @@ public final int getMaxMessagesPerTask() { */ public void setIdleConsumerLimit(int idleConsumerLimit) { Assert.isTrue(idleConsumerLimit > 0, "'idleConsumerLimit' must be 1 or higher"); - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { this.idleConsumerLimit = idleConsumerLimit; } + finally { + this.lifecycleLock.unlock(); + } } /** * Return the limit for the number of idle consumers. */ public final int getIdleConsumerLimit() { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { return this.idleConsumerLimit; } + finally { + this.lifecycleLock.unlock(); + } } /** @@ -496,7 +562,7 @@ public final int getIdleConsumerLimit() { * The minimum number of consumers * (see {@link #setConcurrentConsumers "concurrentConsumers"}) * will be kept around until shutdown in any case. - *

    Within each task execution, a number of message reception attempts + *

    Within each task execution, a number of message receipt attempts * (according to the "maxMessagesPerTask" setting) will each wait for an incoming * message (according to the "receiveTimeout" setting). If all of those receive * attempts in a given task return without a message, the task is considered @@ -515,18 +581,26 @@ public final int getIdleConsumerLimit() { */ public void setIdleTaskExecutionLimit(int idleTaskExecutionLimit) { Assert.isTrue(idleTaskExecutionLimit > 0, "'idleTaskExecutionLimit' must be 1 or higher"); - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { this.idleTaskExecutionLimit = idleTaskExecutionLimit; } + finally { + this.lifecycleLock.unlock(); + } } /** * Return the limit for idle executions of a consumer task. */ public final int getIdleTaskExecutionLimit() { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { return this.idleTaskExecutionLimit; } + finally { + this.lifecycleLock.unlock(); + } } /** @@ -550,15 +624,29 @@ public final int getIdleTaskExecutionLimit() { * 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. + *

    NOTE: On its own, this idle limit does not apply to core consumers within + * {@link #setConcurrentConsumers} but rather just to surplus consumers up until + * {@link #setMaxConcurrentConsumers} (as of 6.2). Only in combination with + * {@link #setMaxMessagesPerTask} does it have an effect on core consumers as well, + * as inferred for an external thread pool indicating a preference for short-lived + * tasks, leading to dynamic rescheduling of all consumer tasks in the thread pool. + *

    The default for surplus consumers on a default/simple executor is 10, + * leading to a removal of surplus tasks after 10 idle receives in each task. + * In combination with the default {@link #setReceiveTimeout} of 1000 ms (1 second), + * a surplus task will be scaled down after 10 seconds of idle receives by default. * @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.lifecycleLock.lock(); + try { this.idleReceivesPerTaskLimit = idleReceivesPerTaskLimit; } + finally { + this.lifecycleLock.unlock(); + } } /** @@ -567,9 +655,13 @@ public void setIdleReceivesPerTaskLimit(int idleReceivesPerTaskLimit) { * @since 5.3.5 */ public int getIdleReceivesPerTaskLimit() { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { return this.idleReceivesPerTaskLimit; } + finally { + this.lifecycleLock.unlock(); + } } @@ -584,19 +676,29 @@ public void initialize() { this.cacheLevel = (getTransactionManager() != null ? CACHE_NONE : CACHE_CONSUMER); } - // Prepare taskExecutor and maxMessagesPerTask. - synchronized (this.lifecycleMonitor) { + // Prepare taskExecutor and maxMessagesPerTask/idleReceivesPerTaskLimit. + this.lifecycleLock.lock(); + try { if (this.taskExecutor == null) { this.taskExecutor = createDefaultTaskExecutor(); } - else if (this.taskExecutor instanceof SchedulingTaskExecutor ste && ste.prefersShortLivedTasks() && - this.maxMessagesPerTask == Integer.MIN_VALUE) { - // TaskExecutor indicated a preference for short-lived tasks. According to - // setMaxMessagesPerTask javadoc, we'll use 10 message per task in this case - // unless the user specified a custom value. - this.maxMessagesPerTask = 10; + if (this.taskExecutor instanceof SchedulingTaskExecutor ste && ste.prefersShortLivedTasks()) { + if (this.maxMessagesPerTask == Integer.MIN_VALUE) { + // TaskExecutor indicated a preference for short-lived tasks. According to + // setMaxMessagesPerTask javadoc, we'll use 10 message per task in this case + // unless the user specified a custom value. + this.maxMessagesPerTask = 10; + } + } + else if (this.idleReceivesPerTaskLimit == Integer.MIN_VALUE) { + // A simple non-pooling executor: unlimited core consumer tasks + // whereas surplus consumer tasks terminate after 10 idle receives. + this.idleReceivesPerTaskLimit = 10; } } + finally { + this.lifecycleLock.unlock(); + } // Proceed with actual listener initialization. super.initialize(); @@ -611,11 +713,15 @@ else if (this.taskExecutor instanceof SchedulingTaskExecutor ste && ste.prefersS */ @Override protected void doInitialize() throws JMSException { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { for (int i = 0; i < this.concurrentConsumers; i++) { scheduleNewInvoker(); } } + finally { + this.lifecycleLock.unlock(); + } } /** @@ -624,44 +730,46 @@ protected void doInitialize() throws JMSException { @Override protected void doShutdown() throws JMSException { logger.debug("Waiting for shutdown of message listener invokers"); + this.lifecycleLock.lock(); try { - synchronized (this.lifecycleMonitor) { - long receiveTimeout = getReceiveTimeout(); - long waitStartTime = System.currentTimeMillis(); - int waitCount = 0; - while (this.activeInvokerCount > 0) { - if (waitCount > 0 && !isAcceptMessagesWhileStopping() && - System.currentTimeMillis() - waitStartTime >= receiveTimeout) { - // Unexpectedly some invokers are still active after the receive timeout period - // -> interrupt remaining receive attempts since we'd reject the messages anyway - for (AsyncMessageListenerInvoker scheduledInvoker : this.scheduledInvokers) { - scheduledInvoker.interruptIfNecessary(); - } - } - if (logger.isDebugEnabled()) { - logger.debug("Still waiting for shutdown of " + this.activeInvokerCount + - " message listener invokers (iteration " + waitCount + ")"); - } - // Wait for AsyncMessageListenerInvokers to deactivate themselves... - if (receiveTimeout > 0) { - this.lifecycleMonitor.wait(receiveTimeout); - } - else { - this.lifecycleMonitor.wait(); + long receiveTimeout = getReceiveTimeout(); + long waitStartTime = System.currentTimeMillis(); + int waitCount = 0; + while (this.activeInvokerCount > 0) { + if (waitCount > 0 && !isAcceptMessagesWhileStopping() && + System.currentTimeMillis() - waitStartTime >= receiveTimeout) { + // Unexpectedly some invokers are still active after the receive timeout period + // -> interrupt remaining receive attempts since we'd reject the messages anyway + for (AsyncMessageListenerInvoker scheduledInvoker : this.scheduledInvokers) { + scheduledInvoker.interruptIfNecessary(); } - waitCount++; } - // Clear remaining scheduled invokers, possibly left over as paused tasks - for (AsyncMessageListenerInvoker scheduledInvoker : this.scheduledInvokers) { - scheduledInvoker.clearResources(); + if (logger.isDebugEnabled()) { + logger.debug("Still waiting for shutdown of " + this.activeInvokerCount + + " message listener invokers (iteration " + waitCount + ")"); + } + // Wait for AsyncMessageListenerInvokers to deactivate themselves... + if (receiveTimeout > 0) { + this.lifecycleCondition.await(receiveTimeout, TimeUnit.MILLISECONDS); + } + else { + this.lifecycleCondition.await(); } - this.scheduledInvokers.clear(); + waitCount++; + } + // Clear remaining scheduled invokers, possibly left over as paused tasks + for (AsyncMessageListenerInvoker scheduledInvoker : this.scheduledInvokers) { + scheduledInvoker.clearResources(); } + this.scheduledInvokers.clear(); } catch (InterruptedException ex) { // Re-interrupt current thread, to allow other threads to react. Thread.currentThread().interrupt(); } + finally { + this.lifecycleLock.unlock(); + } } /** @@ -669,9 +777,13 @@ protected void doShutdown() throws JMSException { */ @Override public void start() throws JmsException { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { this.stopCallback = null; } + finally { + this.lifecycleLock.unlock(); + } super.start(); } @@ -690,7 +802,8 @@ public void start() throws JmsException { */ @Override public void stop(Runnable callback) throws JmsException { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { if (!isRunning() || this.stopCallback != null) { // Not started, already stopped, or previous stop attempt in progress // -> return immediately, no stop process to control anymore. @@ -699,6 +812,9 @@ public void stop(Runnable callback) throws JmsException { } this.stopCallback = callback; } + finally { + this.lifecycleLock.unlock(); + } stop(); } @@ -712,9 +828,13 @@ public void stop(Runnable callback) throws JmsException { * @see #getActiveConsumerCount() */ public final int getScheduledConsumerCount() { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { return this.scheduledInvokers.size(); } + finally { + this.lifecycleLock.unlock(); + } } /** @@ -727,9 +847,13 @@ public final int getScheduledConsumerCount() { * @see #getActiveConsumerCount() */ public final int getActiveConsumerCount() { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { return this.activeInvokerCount; } + finally { + this.lifecycleLock.unlock(); + } } /** @@ -748,9 +872,13 @@ public final int getActiveConsumerCount() { * only {@link #CACHE_CONSUMER} will lead to a fixed registration. */ public boolean isRegisteredWithDestination() { - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { return (this.registeredWithDestination > 0); } + finally { + this.lifecycleLock.unlock(); + } } @@ -759,11 +887,15 @@ public boolean isRegisteredWithDestination() { *

    The default implementation builds a {@link org.springframework.core.task.SimpleAsyncTaskExecutor} * with the specified bean name (or the class name, if no bean name specified) as thread name prefix. * @see org.springframework.core.task.SimpleAsyncTaskExecutor#SimpleAsyncTaskExecutor(String) + * @see #setVirtualThreads */ protected TaskExecutor createDefaultTaskExecutor() { String beanName = getBeanName(); String threadNamePrefix = (beanName != null ? beanName + "-" : DEFAULT_THREAD_NAME_PREFIX); - return new SimpleAsyncTaskExecutor(threadNamePrefix); + + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(threadNamePrefix); + executor.setVirtualThreads(this.virtualThreads); + return executor; } /** @@ -830,7 +962,8 @@ protected void noMessageReceived(Object invoker, Session session) { protected void scheduleNewInvokerIfAppropriate() { if (isRunning()) { resumePausedTasks(); - synchronized (this.lifecycleMonitor) { + this.lifecycleLock.lock(); + try { if (this.scheduledInvokers.size() < this.maxConcurrentConsumers && getIdleInvokerCount() < this.idleConsumerLimit) { scheduleNewInvoker(); @@ -839,6 +972,9 @@ protected void scheduleNewInvokerIfAppropriate() { } } } + finally { + this.lifecycleLock.unlock(); + } } } @@ -1071,10 +1207,9 @@ protected boolean applyBackOffTime(BackOffExecution execution) { return false; } else { + this.lifecycleLock.lock(); try { - synchronized (this.lifecycleMonitor) { - this.lifecycleMonitor.wait(interval); - } + this.lifecycleCondition.await(interval, TimeUnit.MILLISECONDS); } catch (InterruptedException interEx) { // Re-interrupt current thread, to allow other threads to react. @@ -1083,6 +1218,9 @@ protected boolean applyBackOffTime(BackOffExecution execution) { this.interrupted = true; } } + finally { + this.lifecycleLock.unlock(); + } return true; } } @@ -1128,15 +1266,24 @@ private class AsyncMessageListenerInvoker implements SchedulingAwareRunnable { @Override public void run() { - synchronized (lifecycleMonitor) { + boolean surplus; + lifecycleLock.lock(); + try { + surplus = (scheduledInvokers.size() > concurrentConsumers); activeInvokerCount++; - lifecycleMonitor.notifyAll(); + lifecycleCondition.signalAll(); + } + finally { + lifecycleLock.unlock(); } boolean messageReceived = false; try { + // For core consumers without maxMessagesPerTask, no idle limit applies since they + // will always get rescheduled immediately anyway. Whereas for surplus consumers + // between concurrentConsumers and maxConcurrentConsumers, an idle limit does apply. int messageLimit = maxMessagesPerTask; int idleLimit = idleReceivesPerTaskLimit; - if (messageLimit < 0 && idleLimit < 0) { + if (messageLimit < 0 && (!surplus || idleLimit < 0)) { messageReceived = executeOngoingLoop(); } else { @@ -1160,7 +1307,8 @@ public void run() { } this.lastMessageSucceeded = false; boolean alreadyRecovered = false; - synchronized (recoveryMonitor) { + recoveryLock.lock(); + try { if (this.lastRecoveryMarker == currentRecoveryMarker) { handleListenerSetupFailure(ex, false); recoverAfterListenerSetupFailure(); @@ -1170,14 +1318,21 @@ public void run() { alreadyRecovered = true; } } + finally { + recoveryLock.unlock(); + } if (alreadyRecovered) { handleListenerSetupFailure(ex, true); } } finally { - synchronized (lifecycleMonitor) { + lifecycleLock.lock(); + try { decreaseActiveInvokerCount(); - lifecycleMonitor.notifyAll(); + lifecycleCondition.signalAll(); + } + finally { + lifecycleLock.unlock(); } if (!messageReceived) { this.idleTaskExecutionCount++; @@ -1185,14 +1340,15 @@ public void run() { else { this.idleTaskExecutionCount = 0; } - synchronized (lifecycleMonitor) { + lifecycleLock.lock(); + try { if (!shouldRescheduleInvoker(this.idleTaskExecutionCount) || !rescheduleTaskIfNecessary(this)) { // We're shutting down completely. scheduledInvokers.remove(this); if (logger.isDebugEnabled()) { logger.debug("Lowered scheduled invoker count: " + scheduledInvokers.size()); } - lifecycleMonitor.notifyAll(); + lifecycleCondition.signalAll(); clearResources(); } else if (isRunning()) { @@ -1208,6 +1364,9 @@ else if (nonPausedConsumers < getConcurrentConsumers()) { } } } + finally { + lifecycleLock.unlock(); + } } } @@ -1215,7 +1374,8 @@ private boolean executeOngoingLoop() throws JMSException { boolean messageReceived = false; boolean active = true; while (active) { - synchronized (lifecycleMonitor) { + lifecycleLock.lock(); + try { boolean interrupted = false; boolean wasWaiting = false; while ((active = isActive()) && !isRunning()) { @@ -1228,7 +1388,7 @@ private boolean executeOngoingLoop() throws JMSException { } wasWaiting = true; try { - lifecycleMonitor.wait(); + lifecycleCondition.await(); } catch (InterruptedException ex) { // Re-interrupt current thread, to allow other threads to react. @@ -1243,6 +1403,9 @@ private boolean executeOngoingLoop() throws JMSException { active = false; } } + finally { + lifecycleLock.unlock(); + } if (active) { messageReceived = (invokeListener() || messageReceived); } @@ -1277,6 +1440,7 @@ private void decreaseActiveInvokerCount() { } } + @SuppressWarnings("NullAway") private void initResourcesIfNecessary() throws JMSException { if (getCacheLevel() <= CACHE_CONNECTION) { updateRecoveryMarker(); @@ -1288,17 +1452,25 @@ private void initResourcesIfNecessary() throws JMSException { } if (this.consumer == null && getCacheLevel() >= CACHE_CONSUMER) { this.consumer = createListenerConsumer(this.session); - synchronized (lifecycleMonitor) { + lifecycleLock.lock(); + try { registeredWithDestination++; } + finally { + lifecycleLock.unlock(); + } } } } private void updateRecoveryMarker() { - synchronized (recoveryMonitor) { + recoveryLock.lock(); + try { this.lastRecoveryMarker = currentRecoveryMarker; } + finally { + recoveryLock.unlock(); + } } private void interruptIfNecessary() { @@ -1310,19 +1482,27 @@ private void interruptIfNecessary() { private void clearResources() { if (sharedConnectionEnabled()) { - synchronized (sharedConnectionMonitor) { + sharedConnectionLock.lock(); + try { JmsUtils.closeMessageConsumer(this.consumer); JmsUtils.closeSession(this.session); } + finally { + sharedConnectionLock.unlock(); + } } else { JmsUtils.closeMessageConsumer(this.consumer); JmsUtils.closeSession(this.session); } if (this.consumer != null) { - synchronized (lifecycleMonitor) { + lifecycleLock.lock(); + try { registeredWithDestination--; } + finally { + lifecycleLock.unlock(); + } } this.consumer = null; this.session = null; diff --git a/spring-jms/src/main/java/org/springframework/jms/listener/SimpleMessageListenerContainer.java b/spring-jms/src/main/java/org/springframework/jms/listener/SimpleMessageListenerContainer.java index 27a72421f642..e445eb34e1c4 100644 --- a/spring-jms/src/main/java/org/springframework/jms/listener/SimpleMessageListenerContainer.java +++ b/spring-jms/src/main/java/org/springframework/jms/listener/SimpleMessageListenerContainer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author 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,8 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.Executor; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import jakarta.jms.Connection; import jakarta.jms.ConnectionFactory; @@ -54,7 +56,7 @@ * *

    For a different style of MessageListener handling, through looped * {@code MessageConsumer.receive()} calls that also allow for - * transactional reception of messages (registering them with XA transactions), + * transactional receipt of messages (registering them with XA transactions), * see {@link DefaultMessageListenerContainer}. * * @author Juergen Hoeller @@ -80,7 +82,7 @@ public class SimpleMessageListenerContainer extends AbstractMessageListenerConta @Nullable private Set consumers; - private final Object consumersMonitor = new Object(); + private final Lock consumersLock = new ReentrantLock(); /** @@ -113,8 +115,8 @@ public void setRecoverOnException(boolean recoverOnException) { } /** - * Specify concurrency limits via a "lower-upper" String, e.g. "5-10", or a simple - * upper limit String, e.g. "10". + * Specify concurrency limits via a "lower-upper" String, for example, "5-10", or a simple + * upper limit String, for example, "10". *

    This listener container will always hold on to the maximum number of * consumers {@link #setConcurrentConsumers} since it is unable to scale. *

    This property is primarily supported for configuration compatibility with @@ -134,7 +136,7 @@ public void setConcurrency(String concurrency) { } catch (NumberFormatException ex) { throw new IllegalArgumentException("Invalid concurrency value [" + concurrency + "]: only " + - "single maximum integer (e.g. \"5\") and minimum-maximum combo (e.g. \"3-5\") supported. " + + "single maximum integer (for example, \"5\") and minimum-maximum combo (for example, \"3-5\") supported. " + "Note that SimpleMessageListenerContainer will effectively ignore the minimum value and " + "always keep a fixed number of consumers according to the maximum value."); } @@ -261,10 +263,14 @@ public void onException(JMSException ex) { logger.debug("Trying to recover from JMS Connection exception: " + ex); } try { - synchronized (this.consumersMonitor) { + this.consumersLock.lock(); + try { this.sessions = null; this.consumers = null; } + finally { + this.consumersLock.unlock(); + } refreshSharedConnection(); initializeConsumers(); logger.debug("Successfully refreshed JMS Connection"); @@ -282,7 +288,8 @@ public void onException(JMSException ex) { */ protected void initializeConsumers() throws JMSException { // Register Sessions and MessageConsumers. - synchronized (this.consumersMonitor) { + this.consumersLock.lock(); + try { if (this.consumers == null) { this.sessions = new HashSet<>(this.concurrentConsumers); this.consumers = new HashSet<>(this.concurrentConsumers); @@ -295,6 +302,9 @@ protected void initializeConsumers() throws JMSException { } } } + finally { + this.consumersLock.unlock(); + } } /** @@ -305,6 +315,7 @@ protected void initializeConsumers() throws JMSException { * @throws JMSException if thrown by JMS methods * @see #executeListener */ + @SuppressWarnings("NullAway") protected MessageConsumer createListenerConsumer(final Session session) throws JMSException { Destination destination = getDestination(); if (destination == null) { @@ -333,6 +344,7 @@ protected MessageConsumer createListenerConsumer(final Session session) throws J * @see #executeListener * @see #setExposeListenerSession */ + @SuppressWarnings("NullAway") protected void processMessage(Message message, Session session) { ConnectionFactory connectionFactory = getConnectionFactory(); boolean exposeResource = (connectionFactory != null && isExposeListenerSession()); @@ -355,7 +367,8 @@ protected void processMessage(Message message, Session session) { */ @Override protected void doShutdown() throws JMSException { - synchronized (this.consumersMonitor) { + this.consumersLock.lock(); + try { if (this.consumers != null) { logger.debug("Closing JMS MessageConsumers"); for (MessageConsumer consumer : this.consumers) { @@ -369,6 +382,9 @@ protected void doShutdown() throws JMSException { } } } + finally { + this.consumersLock.unlock(); + } } } diff --git a/spring-jms/src/main/java/org/springframework/jms/listener/adapter/MessagingMessageListenerAdapter.java b/spring-jms/src/main/java/org/springframework/jms/listener/adapter/MessagingMessageListenerAdapter.java index 9675c860d6aa..6518a5cd51d9 100644 --- a/spring-jms/src/main/java/org/springframework/jms/listener/adapter/MessagingMessageListenerAdapter.java +++ b/spring-jms/src/main/java/org/springframework/jms/listener/adapter/MessagingMessageListenerAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,7 +43,7 @@ * are provided as additional arguments so that these can be injected as * method arguments if necessary. * - *

    As of Spring Framework 5.3.26, {@code MessagingMessageListenerAdapter} implements + *

    Note that {@code MessagingMessageListenerAdapter} implements * {@link SubscriptionNameProvider} in order to provide a meaningful default * subscription name. See {@link #getSubscriptionName()} for details. * diff --git a/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/DefaultJmsActivationSpecFactory.java b/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/DefaultJmsActivationSpecFactory.java index 751fc355d69d..8e17525462cb 100644 --- a/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/DefaultJmsActivationSpecFactory.java +++ b/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/DefaultJmsActivationSpecFactory.java @@ -30,7 +30,7 @@ * through autodetection of well-known vendor-specific provider properties. * *

    An ActivationSpec factory is effectively dependent on the concrete - * JMS provider, e.g. on ActiveMQ. This default implementation simply + * JMS provider, for example, on ActiveMQ. This default implementation simply * guesses the ActivationSpec class name from the provider's class name * ("ActiveMQResourceAdapter" → "ActiveMQActivationSpec" in the same package, * or "ActivationSpecImpl" in the same package as the ResourceAdapter class), @@ -67,7 +67,7 @@ public class DefaultJmsActivationSpecFactory extends StandardJmsActivationSpecFa /** * This implementation guesses the ActivationSpec class name from the - * provider's class name: e.g. "ActiveMQResourceAdapter" → + * provider's class name: for example, "ActiveMQResourceAdapter" → * "ActiveMQActivationSpec" in the same package, or a class named * "ActivationSpecImpl" in the same package as the ResourceAdapter class. */ @@ -76,7 +76,7 @@ protected Class determineActivationSpecClass(ResourceAdapter adapter) { String adapterClassName = adapter.getClass().getName(); if (adapterClassName.endsWith(RESOURCE_ADAPTER_SUFFIX)) { - // e.g. ActiveMQ + // for example, ActiveMQ String providerName = adapterClassName.substring(0, adapterClassName.length() - RESOURCE_ADAPTER_SUFFIX.length()); String specClassName = providerName + ACTIVATION_SPEC_SUFFIX; @@ -91,7 +91,7 @@ protected Class determineActivationSpecClass(ResourceAdapter adapter) { } else if (adapterClassName.endsWith(RESOURCE_ADAPTER_IMPL_SUFFIX)){ - //e.g. WebSphere + // for example, WebSphere String providerName = adapterClassName.substring(0, adapterClassName.length() - RESOURCE_ADAPTER_IMPL_SUFFIX.length()); String specClassName = providerName + ACTIVATION_SPEC_IMPL_SUFFIX; @@ -105,7 +105,7 @@ else if (adapterClassName.endsWith(RESOURCE_ADAPTER_IMPL_SUFFIX)){ } } - // e.g. JORAM + // for example, JORAM String providerPackage = adapterClassName.substring(0, adapterClassName.lastIndexOf('.') + 1); String specClassName = providerPackage + ACTIVATION_SPEC_IMPL_SUFFIX; try { diff --git a/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/JmsActivationSpecConfig.java b/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/JmsActivationSpecConfig.java index c4069c5f7d93..ec1d22184b5f 100644 --- a/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/JmsActivationSpecConfig.java +++ b/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/JmsActivationSpecConfig.java @@ -230,8 +230,8 @@ public int getAcknowledgeMode() { } /** - * Specify concurrency limits via a "lower-upper" String, e.g. "5-10", or a simple - * upper limit String, e.g. "10". + * Specify concurrency limits via a "lower-upper" String, for example, "5-10", or a simple + * upper limit String, for example, "10". *

    JCA listener containers will always scale from zero to the given upper limit. * A specified lower limit will effectively be ignored. *

    This property is primarily supported for configuration compatibility with @@ -250,7 +250,7 @@ public void setConcurrency(String concurrency) { } catch (NumberFormatException ex) { throw new IllegalArgumentException("Invalid concurrency value [" + concurrency + "]: only " + - "single maximum integer (e.g. \"5\") and minimum-maximum combo (e.g. \"3-5\") supported. " + + "single maximum integer (for example, \"5\") and minimum-maximum combo (for example, \"3-5\") supported. " + "Note that JmsActivationSpecConfig will effectively ignore the minimum value and " + "scale from zero up to the number of consumers according to the maximum value."); } diff --git a/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/JmsMessageEndpointFactory.java b/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/JmsMessageEndpointFactory.java index dfa27883e5f3..6a164236d715 100644 --- a/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/JmsMessageEndpointFactory.java +++ b/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/JmsMessageEndpointFactory.java @@ -29,7 +29,7 @@ * JMS-specific implementation of the JCA 1.7 * {@link jakarta.resource.spi.endpoint.MessageEndpointFactory} interface, * providing transaction management capabilities for a JMS listener object - * (e.g. a {@link jakarta.jms.MessageListener} object). + * (for example, a {@link jakarta.jms.MessageListener} object). * *

    Uses a static endpoint implementation, simply wrapping the * specified message listener object and exposing all of its implemented diff --git a/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/JmsMessageEndpointManager.java b/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/JmsMessageEndpointManager.java index bf87b742bc91..64e380f8137b 100644 --- a/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/JmsMessageEndpointManager.java +++ b/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/JmsMessageEndpointManager.java @@ -102,9 +102,9 @@ public void setTransactionManager(Object transactionManager) { * Set the factory for concrete JCA 1.5 ActivationSpec objects, * creating JCA ActivationSpecs based on * {@link #setActivationSpecConfig JmsActivationSpecConfig} objects. - *

    This factory is dependent on the concrete JMS provider, e.g. on ActiveMQ. + *

    This factory is dependent on the concrete JMS provider, for example, on ActiveMQ. * The default implementation simply guesses the ActivationSpec class name - * from the provider's class name (e.g. "ActiveMQResourceAdapter" → + * from the provider's class name (for example, "ActiveMQResourceAdapter" → * "ActiveMQActivationSpec" in the same package), and populates the * ActivationSpec properties as suggested by the JCA 1.5 specification * (plus a couple of autodetected vendor-specific properties). diff --git a/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/StandardJmsActivationSpecFactory.java b/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/StandardJmsActivationSpecFactory.java index a5ef91e56a37..81232764d511 100644 --- a/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/StandardJmsActivationSpecFactory.java +++ b/spring-jms/src/main/java/org/springframework/jms/listener/endpoint/StandardJmsActivationSpecFactory.java @@ -39,7 +39,7 @@ * *

    The 'activationSpecClass' property is required, explicitly defining * the fully-qualified class name of the provider's ActivationSpec class - * (e.g. "org.apache.activemq.ra.ActiveMQActivationSpec"). + * (for example, "org.apache.activemq.ra.ActiveMQActivationSpec"). * *

    Check out {@link DefaultJmsActivationSpecFactory} for an extended variant * of this class, supporting some further default conventions beyond the plain @@ -64,7 +64,7 @@ public class StandardJmsActivationSpecFactory implements JmsActivationSpecFactor /** * Specify the fully-qualified ActivationSpec class name for the target - * provider (e.g. "org.apache.activemq.ra.ActiveMQActivationSpec"). + * provider (for example, "org.apache.activemq.ra.ActiveMQActivationSpec"). */ public void setActivationSpecClass(Class activationSpecClass) { this.activationSpecClass = activationSpecClass; @@ -86,7 +86,7 @@ public void setDefaultProperties(Map defaultProperties) { *

    If not specified, destination names will simply be passed in as Strings. * If specified, destination names will be resolved into Destination objects first. *

    Note that a DestinationResolver for use with this factory must be - * able to work without an active JMS Session: e.g. + * able to work without an active JMS Session: for example, * {@link org.springframework.jms.support.destination.JndiDestinationResolver} * or {@link org.springframework.jms.support.destination.BeanFactoryDestinationResolver} * but not {@link org.springframework.jms.support.destination.DynamicDestinationResolver}. @@ -213,7 +213,7 @@ else if (bw.isWritableProperty("acknowledgeMode")) { ackMode == Session.DUPS_OK_ACKNOWLEDGE ? "Dups-ok-acknowledge" : "Auto-acknowledge"); } else if (ackMode == Session.DUPS_OK_ACKNOWLEDGE) { - // Standard JCA 1.5 "acknowledgeMode" apparently not supported (e.g. WebSphere MQ 6.0.2.1) + // Standard JCA 1.5 "acknowledgeMode" apparently not supported (for example, WebSphere MQ 6.0.2.1) throw new IllegalArgumentException("Dups-ok-acknowledge not supported by underlying provider"); } } diff --git a/spring-jms/src/main/java/org/springframework/jms/support/JmsHeaderMapper.java b/spring-jms/src/main/java/org/springframework/jms/support/JmsHeaderMapper.java index 75e4d4fa3272..e13fb3ff1736 100644 --- a/spring-jms/src/main/java/org/springframework/jms/support/JmsHeaderMapper.java +++ b/spring-jms/src/main/java/org/springframework/jms/support/JmsHeaderMapper.java @@ -22,7 +22,7 @@ /** * Strategy interface for mapping {@link org.springframework.messaging.Message} - * headers to an outbound JMS {@link jakarta.jms.Message} (e.g. to configure JMS + * headers to an outbound JMS {@link jakarta.jms.Message} (for example, to configure JMS * properties) or extracting messaging header values from an inbound JMS Message. * * @author Mark Fisher diff --git a/spring-jms/src/main/java/org/springframework/jms/support/JmsHeaders.java b/spring-jms/src/main/java/org/springframework/jms/support/JmsHeaders.java index a2e7867e92f7..c45264dd92b9 100644 --- a/spring-jms/src/main/java/org/springframework/jms/support/JmsHeaders.java +++ b/spring-jms/src/main/java/org/springframework/jms/support/JmsHeaders.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ public interface JmsHeaders { /** * Prefix used for JMS API related headers in order to distinguish from - * user-defined headers and other internal headers (e.g. correlationId). + * user-defined headers and other internal headers (for example, correlationId). * @see SimpleJmsHeaderMapper */ String PREFIX = "jms_"; @@ -89,7 +89,7 @@ public interface JmsHeaders { /** * Specify if the message was resent. This occurs when a message - * consumer fails to acknowledge the message reception. + * consumer fails to acknowledge receipt of the message. *

    Read-only value. * @see jakarta.jms.Message#getJMSRedelivered() */ diff --git a/spring-jms/src/main/java/org/springframework/jms/support/JmsUtils.java b/spring-jms/src/main/java/org/springframework/jms/support/JmsUtils.java index 3c6f49a26674..b4815924fa10 100644 --- a/spring-jms/src/main/java/org/springframework/jms/support/JmsUtils.java +++ b/spring-jms/src/main/java/org/springframework/jms/support/JmsUtils.java @@ -74,12 +74,9 @@ public static void closeConnection(@Nullable Connection con, boolean stop) { if (con != null) { try { if (stop) { - try { + try (con) { con.stop(); } - finally { - con.close(); - } } else { con.close(); diff --git a/spring-jms/src/main/java/org/springframework/jms/support/SimpleJmsHeaderMapper.java b/spring-jms/src/main/java/org/springframework/jms/support/SimpleJmsHeaderMapper.java index f093692d3017..f62466d8ee60 100644 --- a/spring-jms/src/main/java/org/springframework/jms/support/SimpleJmsHeaderMapper.java +++ b/spring-jms/src/main/java/org/springframework/jms/support/SimpleJmsHeaderMapper.java @@ -32,13 +32,13 @@ /** * Simple implementation of {@link JmsHeaderMapper}. * - *

    This implementation copies JMS API headers (e.g. JMSReplyTo) to and from + *

    This implementation copies JMS API headers (for example, JMSReplyTo) to and from * {@link org.springframework.messaging.Message Messages}. Any user-defined * properties will also be copied from a JMS Message to a Message, and any * other headers on a Message (beyond the JMS API headers) will likewise * be copied to a JMS Message. Those other headers will be copied to the * general properties of a JMS Message whereas the JMS API headers are passed - * to the appropriate setter methods (e.g. setJMSReplyTo). + * to the appropriate setter methods (for example, setJMSReplyTo). * *

    Constants for the JMS API headers are defined in {@link JmsHeaders}. * Note that most of the JMS headers are read-only: the JMSDestination, diff --git a/spring-jms/src/main/java/org/springframework/jms/support/converter/MappingJackson2MessageConverter.java b/spring-jms/src/main/java/org/springframework/jms/support/converter/MappingJackson2MessageConverter.java index 0a23907a1ea0..50e1099a4091 100644 --- a/spring-jms/src/main/java/org/springframework/jms/support/converter/MappingJackson2MessageConverter.java +++ b/spring-jms/src/main/java/org/springframework/jms/support/converter/MappingJackson2MessageConverter.java @@ -304,7 +304,7 @@ protected BytesMessage mapToBytesMessage(Object object, Session session, ObjectW objectWriter.writeValue(writer, object); } else { - // Jackson usually defaults to UTF-8 but can also go straight to bytes, e.g. for Smile. + // Jackson usually defaults to UTF-8 but can also go straight to bytes, for example, for Smile. // We use a direct byte array argument for the latter case to work as well. objectWriter.writeValue(bos, object); } @@ -445,7 +445,7 @@ protected Object convertFromMessage(Message message, JavaType targetJavaType) * typically parsing a type id message property. *

    The default implementation parses the configured type id property name * and consults the configured type id mapping. This can be overridden with - * a different strategy, e.g. doing some heuristics based on message origin. + * a different strategy, for example, doing some heuristics based on message origin. * @param message the JMS Message from which to get the type id property * @throws JMSException if thrown by JMS methods * @see #setTypeIdOnMessage(Object, jakarta.jms.Message) diff --git a/spring-jms/src/main/java/org/springframework/jms/support/converter/MessagingMessageConverter.java b/spring-jms/src/main/java/org/springframework/jms/support/converter/MessagingMessageConverter.java index 823f6073ba95..188ff2348428 100644 --- a/spring-jms/src/main/java/org/springframework/jms/support/converter/MessagingMessageConverter.java +++ b/spring-jms/src/main/java/org/springframework/jms/support/converter/MessagingMessageConverter.java @@ -132,7 +132,7 @@ protected Object extractPayload(jakarta.jms.Message message) throws JMSException /** * Create a JMS message for the specified payload and conversionHint. * The conversion hint is an extra object passed to the {@link MessageConverter}, - * e.g. the associated {@code MethodParameter} (may be {@code null}}. + * for example, the associated {@code MethodParameter} (may be {@code null}}. * @since 4.3 * @see MessageConverter#toMessage(Object, Session) */ diff --git a/spring-jms/src/main/java/org/springframework/jms/support/converter/SmartMessageConverter.java b/spring-jms/src/main/java/org/springframework/jms/support/converter/SmartMessageConverter.java index ab180e4ecebc..3a6468d78c83 100644 --- a/spring-jms/src/main/java/org/springframework/jms/support/converter/SmartMessageConverter.java +++ b/spring-jms/src/main/java/org/springframework/jms/support/converter/SmartMessageConverter.java @@ -36,12 +36,12 @@ public interface SmartMessageConverter extends MessageConverter { /** * A variant of {@link #toMessage(Object, Session)} which takes an extra conversion - * context as an argument, allowing to take e.g. annotations on a payload parameter + * context as an argument, allowing to take, for example, annotations on a payload parameter * into account. * @param object the object to convert * @param session the Session to use for creating a JMS Message * @param conversionHint an extra object passed to the {@link MessageConverter}, - * e.g. the associated {@code MethodParameter} (may be {@code null}} + * for example, the associated {@code MethodParameter} (may be {@code null}} * @return the JMS Message * @throws jakarta.jms.JMSException if thrown by JMS API methods * @throws MessageConversionException in case of conversion failure diff --git a/spring-jms/src/main/resources/org/springframework/jms/config/spring-jms.xsd b/spring-jms/src/main/resources/org/springframework/jms/config/spring-jms.xsd index e38673903d83..5101b978e801 100644 --- a/spring-jms/src/main/resources/org/springframework/jms/config/spring-jms.xsd +++ b/spring-jms/src/main/resources/org/springframework/jms/config/spring-jms.xsd @@ -296,7 +296,7 @@ @@ -310,8 +310,8 @@ @@ -624,8 +624,8 @@ diff --git a/spring-jms/src/test/java/org/springframework/jms/StubTextMessage.java b/spring-jms/src/test/java/org/springframework/jms/StubTextMessage.java index efde0956ad94..a1ab40f16c6a 100644 --- a/spring-jms/src/test/java/org/springframework/jms/StubTextMessage.java +++ b/spring-jms/src/test/java/org/springframework/jms/StubTextMessage.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -325,6 +325,7 @@ public T getBody(Class c) { } @Override + @SuppressWarnings("rawtypes") public boolean isBodyAssignableTo(Class c) { return false; } diff --git a/spring-jms/src/test/java/org/springframework/jms/connection/UserCredentialsConnectionFactoryAdapterTests.java b/spring-jms/src/test/java/org/springframework/jms/connection/UserCredentialsConnectionFactoryAdapterTests.java new file mode 100644 index 000000000000..f3ea7f97a04b --- /dev/null +++ b/spring-jms/src/test/java/org/springframework/jms/connection/UserCredentialsConnectionFactoryAdapterTests.java @@ -0,0 +1,144 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.connection; + +import jakarta.jms.ConnectionFactory; +import jakarta.jms.JMSContext; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * Tests for {@link UserCredentialsConnectionFactoryAdapter}. + * + * @author Stephane Nicoll + */ +class UserCredentialsConnectionFactoryAdapterTests { + + private static final JMSContext MOCK_CONTEXT = mock(JMSContext.class); + + private final ConnectionFactory target; + + private final UserCredentialsConnectionFactoryAdapter adapter; + + UserCredentialsConnectionFactoryAdapterTests() { + this.target = mock(ConnectionFactory.class); + this.adapter = new UserCredentialsConnectionFactoryAdapter(); + this.adapter.setTargetConnectionFactory(this.target); + } + + @Test + void createContextWhenNoAuthentication() { + given(this.target.createContext()).willReturn(MOCK_CONTEXT); + assertThat(this.adapter.createContext()).isSameAs(MOCK_CONTEXT); + verify(this.target).createContext(); + verifyNoMoreInteractions(this.target); + } + + @Test + void createContextWhenAuthentication() { + this.adapter.setUsername("user"); + this.adapter.setPassword("password"); + given(this.target.createContext("user", "password")).willReturn(MOCK_CONTEXT); + assertThat(this.adapter.createContext()).isSameAs(MOCK_CONTEXT); + verify(this.target).createContext("user", "password"); + verifyNoMoreInteractions(this.target); + } + + @Test + void createContextWhenThreadLevelAuthentication() { + this.adapter.setCredentialsForCurrentThread("user", "password"); + given(this.target.createContext("user", "password")).willReturn(MOCK_CONTEXT); + assertThat(this.adapter.createContext()).isSameAs(MOCK_CONTEXT); + verify(this.target).createContext("user", "password"); + verifyNoMoreInteractions(this.target); + } + + @Test + void createContextWhenAuthenticationAndThreadLevelAuthentication() { + this.adapter.setCredentialsForCurrentThread("specific", "secret"); + this.adapter.setUsername("user"); + this.adapter.setPassword("password"); + given(this.target.createContext("specific", "secret")).willReturn(MOCK_CONTEXT); + assertThat(this.adapter.createContext()).isSameAs(MOCK_CONTEXT); + verify(this.target).createContext("specific", "secret"); + verifyNoMoreInteractions(this.target); + } + + @Test + void createContextWithSessionModeWhenNoAuthentication() { + given(this.target.createContext(1)).willReturn(MOCK_CONTEXT); + assertThat(this.adapter.createContext(1)).isSameAs(MOCK_CONTEXT); + verify(this.target).createContext(1); + verifyNoMoreInteractions(this.target); + } + + @Test + void createContextWithSessionModeWhenAuthentication() { + this.adapter.setUsername("user"); + this.adapter.setPassword("password"); + given(this.target.createContext("user", "password", 1)).willReturn(MOCK_CONTEXT); + assertThat(this.adapter.createContext(1)).isSameAs(MOCK_CONTEXT); + verify(this.target).createContext("user", "password", 1); + verifyNoMoreInteractions(this.target); + } + + @Test + void createContextWithSessionModeWhenThreadLevelAuthentication() { + this.adapter.setCredentialsForCurrentThread("user", "password"); + given(this.target.createContext("user", "password", 1)).willReturn(MOCK_CONTEXT); + assertThat(this.adapter.createContext(1)).isSameAs(MOCK_CONTEXT); + verify(this.target).createContext("user", "password", 1); + verifyNoMoreInteractions(this.target); + } + + @Test + void createContextWithSessionModeWhenAuthenticationAndThreadLevelAuthentication() { + this.adapter.setCredentialsForCurrentThread("specific", "secret"); + this.adapter.setUsername("user"); + this.adapter.setPassword("password"); + given(this.target.createContext("specific", "secret", 1)).willReturn(MOCK_CONTEXT); + assertThat(this.adapter.createContext(1)).isSameAs(MOCK_CONTEXT); + verify(this.target).createContext("specific", "secret", 1); + verifyNoMoreInteractions(this.target); + } + + @Test + void createContextWithUsernamePasswordIgnoresAuthentication() { + this.adapter.setUsername("user"); + this.adapter.setPassword("password"); + given(this.target.createContext("specific", "secret")).willReturn(MOCK_CONTEXT); + assertThat(this.adapter.createContext("specific", "secret")).isSameAs(MOCK_CONTEXT); + verify(this.target).createContext("specific", "secret"); + verifyNoMoreInteractions(this.target); + } + + @Test + void createContextWithSessionModeAndUsernamePasswordIgnoresAuthentication() { + this.adapter.setUsername("user"); + this.adapter.setPassword("password"); + given(this.target.createContext("specific", "secret", 1)).willReturn(MOCK_CONTEXT); + assertThat(this.adapter.createContext("specific", "secret", 1)).isSameAs(MOCK_CONTEXT); + verify(this.target).createContext("specific", "secret", 1); + verifyNoMoreInteractions(this.target); + } + +} diff --git a/spring-jms/src/test/java/org/springframework/jms/core/JmsTemplateObservationTests.java b/spring-jms/src/test/java/org/springframework/jms/core/JmsTemplateObservationTests.java index 99ae2fe426a1..2798b57683c2 100644 --- a/spring-jms/src/test/java/org/springframework/jms/core/JmsTemplateObservationTests.java +++ b/spring-jms/src/test/java/org/springframework/jms/core/JmsTemplateObservationTests.java @@ -27,13 +27,13 @@ import jakarta.jms.Session; import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory; import org.apache.activemq.artemis.junit.EmbeddedActiveMQExtension; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import static io.micrometer.observation.tck.TestObservationRegistryAssert.assertThat; +import static org.assertj.core.api.Assertions.assertThat; /** * Tests for Observability related {@link JmsTemplate}. @@ -127,7 +127,7 @@ public Message createMessage(Session session) throws JMSException { }); String responseBody = response.getBody(String.class); - Assertions.assertThat(responseBody).isEqualTo("response content"); + assertThat(responseBody).isEqualTo("response content"); assertThat(registry).hasNumberOfObservationsWithNameEqualTo("jms.message.publish", 2); assertThat(registry).hasObservationWithNameEqualTo("jms.message.process").that() diff --git a/spring-jms/src/test/java/org/springframework/jms/listener/DefaultMessageListenerContainerTests.java b/spring-jms/src/test/java/org/springframework/jms/listener/DefaultMessageListenerContainerTests.java index ccc758a22117..9f773a17a71c 100644 --- a/spring-jms/src/test/java/org/springframework/jms/listener/DefaultMessageListenerContainerTests.java +++ b/spring-jms/src/test/java/org/springframework/jms/listener/DefaultMessageListenerContainerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-jms/src/test/java/org/springframework/jms/listener/MessageListenerContainerObservationTests.java b/spring-jms/src/test/java/org/springframework/jms/listener/MessageListenerContainerObservationTests.java index f3879fe72e8a..4aa175e317dd 100644 --- a/spring-jms/src/test/java/org/springframework/jms/listener/MessageListenerContainerObservationTests.java +++ b/spring-jms/src/test/java/org/springframework/jms/listener/MessageListenerContainerObservationTests.java @@ -28,7 +28,6 @@ import jakarta.jms.TextMessage; import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory; import org.apache.activemq.artemis.junit.EmbeddedActiveMQExtension; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; @@ -39,8 +38,8 @@ import org.springframework.jms.core.JmsTemplate; import static io.micrometer.observation.tck.TestObservationRegistryAssert.assertThat; -import static org.junit.jupiter.api.Named.named; -import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.argumentSet; /** * Observation tests for {@link AbstractMessageListenerContainer} implementations. @@ -62,7 +61,7 @@ void setupServer() { connectionFactory = new ActiveMQConnectionFactory(server.getVmURL()); } - @ParameterizedTest(name = "[{index}] {0}") + @ParameterizedTest @MethodSource("listenerContainers") void shouldRecordJmsProcessObservations(AbstractMessageListenerContainer listenerContainer) throws Exception { CountDownLatch latch = new CountDownLatch(1); @@ -84,13 +83,13 @@ void shouldRecordJmsProcessObservations(AbstractMessageListenerContainer listene listenerContainer.shutdown(); } - @ParameterizedTest(name = "[{index}] {0}") + @ParameterizedTest @MethodSource("listenerContainers") void shouldRecordJmsPublishObservations(AbstractMessageListenerContainer listenerContainer) throws Exception { listenerContainer.setConnectionFactory(connectionFactory); listenerContainer.setObservationRegistry(registry); listenerContainer.setDestinationName("spring.test.observation"); - listenerContainer.setMessageListener((SessionAwareMessageListener) (message, session) -> { + listenerContainer.setMessageListener((SessionAwareMessageListener) (message, session) -> { Message response = session.createTextMessage("test response"); session.createProducer(message.getJMSReplyTo()).send(response); }); @@ -104,12 +103,12 @@ void shouldRecordJmsPublishObservations(AbstractMessageListenerContainer listene // response sent to the template assertThat(registry).hasNumberOfObservationsWithNameEqualTo("jms.message.publish", 1); - Assertions.assertThat(response.getText()).isEqualTo("test response"); + assertThat(response.getText()).isEqualTo("test response"); listenerContainer.stop(); listenerContainer.shutdown(); } - @ParameterizedTest(name = "[{index}] {0}") + @ParameterizedTest @MethodSource("listenerContainers") void shouldHaveObservationScopeInErrorHandler(AbstractMessageListenerContainer listenerContainer) throws Exception { CountDownLatch latch = new CountDownLatch(1); @@ -129,7 +128,7 @@ void shouldHaveObservationScopeInErrorHandler(AbstractMessageListenerContainer l JmsTemplate jmsTemplate = new JmsTemplate(connectionFactory); jmsTemplate.convertAndSend("spring.test.observation", "message content"); latch.await(2, TimeUnit.SECONDS); - Assertions.assertThat(observationInErrorHandler.get()).isNotNull(); + assertThat(observationInErrorHandler.get()).isNotNull(); assertThat(registry).hasObservationWithNameEqualTo("jms.message.process") .that() .hasHighCardinalityKeyValue("messaging.destination.name", "spring.test.observation") @@ -141,8 +140,8 @@ void shouldHaveObservationScopeInErrorHandler(AbstractMessageListenerContainer l static Stream listenerContainers() { return Stream.of( - arguments(named(DefaultMessageListenerContainer.class.getSimpleName(), new DefaultMessageListenerContainer())), - arguments(named(SimpleMessageListenerContainer.class.getSimpleName(), new SimpleMessageListenerContainer())) + argumentSet(DefaultMessageListenerContainer.class.getSimpleName(), new DefaultMessageListenerContainer()), + argumentSet(SimpleMessageListenerContainer.class.getSimpleName(), new SimpleMessageListenerContainer()) ); } diff --git a/spring-jms/src/test/java/org/springframework/jms/support/converter/MappingJackson2MessageConverterTests.java b/spring-jms/src/test/java/org/springframework/jms/support/converter/MappingJackson2MessageConverterTests.java index 5c85a6927203..3dee45d9b93e 100644 --- a/spring-jms/src/test/java/org/springframework/jms/support/converter/MappingJackson2MessageConverterTests.java +++ b/spring-jms/src/test/java/org/springframework/jms/support/converter/MappingJackson2MessageConverterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-messaging/src/main/java/org/springframework/messaging/PollableChannel.java b/spring-messaging/src/main/java/org/springframework/messaging/PollableChannel.java index 78f1cfc10708..1dc0ba778074 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/PollableChannel.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/PollableChannel.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2013 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +36,9 @@ public interface PollableChannel extends MessageChannel { /** * Receive a message from this channel, blocking until either a message is available * or the specified timeout period elapses. - * @param timeout the timeout in milliseconds or {@link MessageChannel#INDEFINITE_TIMEOUT}. + * @param timeout the timeout in milliseconds or {@link MessageChannel#INDEFINITE_TIMEOUT} * @return the next available {@link Message} or {@code null} if the specified timeout - * period elapses or the message reception is interrupted + * period elapses or the message receipt is interrupted */ @Nullable Message receive(long timeout); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/converter/AbstractJsonMessageConverter.java b/spring-messaging/src/main/java/org/springframework/messaging/converter/AbstractJsonMessageConverter.java index 034b450c71f1..d3032dc5ad06 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/converter/AbstractJsonMessageConverter.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/converter/AbstractJsonMessageConverter.java @@ -34,7 +34,7 @@ import org.springframework.util.MimeType; /** - * Common base class for plain JSON converters, e.g. Gson and JSON-B. + * Common base class for plain JSON converters, for example, Gson and JSON-B. * * @author Juergen Hoeller * @since 5.3 diff --git a/spring-messaging/src/main/java/org/springframework/messaging/converter/AbstractMessageConverter.java b/spring-messaging/src/main/java/org/springframework/messaging/converter/AbstractMessageConverter.java index 840e253fab1f..60c88c0ea9a8 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/converter/AbstractMessageConverter.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/converter/AbstractMessageConverter.java @@ -283,7 +283,7 @@ protected MimeType getDefaultContentType(Object payload) { * @param message the input message * @param targetClass the target class for the conversion * @param conversionHint an extra object passed to the {@link MessageConverter}, - * e.g. the associated {@code MethodParameter} (may be {@code null}} + * for example, the associated {@code MethodParameter} (may be {@code null}} * @return the result of the conversion, or {@code null} if the converter cannot * perform the conversion * @since 4.2 @@ -300,7 +300,7 @@ protected Object convertFromInternal( * @param payload the Object to convert * @param headers optional headers for the message (may be {@code null}) * @param conversionHint an extra object passed to the {@link MessageConverter}, - * e.g. the associated {@code MethodParameter} (may be {@code null}} + * for example, the associated {@code MethodParameter} (may be {@code null}} * @return the resulting payload for the message, or {@code null} if the converter * cannot perform the conversion * @since 4.2 diff --git a/spring-messaging/src/main/java/org/springframework/messaging/converter/MappingJackson2MessageConverter.java b/spring-messaging/src/main/java/org/springframework/messaging/converter/MappingJackson2MessageConverter.java index 715f02e61e0c..d43e991870fb 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/converter/MappingJackson2MessageConverter.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/converter/MappingJackson2MessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -159,6 +159,7 @@ private void configurePrettyPrint() { } + @SuppressWarnings("deprecation") // as of Jackson 2.18: can(De)Serialize @Override protected boolean canConvertFrom(Message message, @Nullable Class targetClass) { if (targetClass == null || !supportsMimeType(message.getHeaders())) { @@ -173,6 +174,7 @@ protected boolean canConvertFrom(Message message, @Nullable Class targetCl return false; } + @SuppressWarnings("deprecation") // as of Jackson 2.18: can(De)Serialize @Override protected boolean canConvertTo(Object payload, @Nullable MessageHeaders headers) { if (!supportsMimeType(headers)) { diff --git a/spring-messaging/src/main/java/org/springframework/messaging/converter/SmartMessageConverter.java b/spring-messaging/src/main/java/org/springframework/messaging/converter/SmartMessageConverter.java index f9ad9902f8b0..cba7ff9c2a6e 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/converter/SmartMessageConverter.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/converter/SmartMessageConverter.java @@ -34,12 +34,12 @@ public interface SmartMessageConverter extends MessageConverter { /** * A variant of {@link #fromMessage(Message, Class)} which takes an extra - * conversion context as an argument, allowing to take e.g. annotations + * conversion context as an argument, allowing to take, for example, annotations * on a payload parameter into account. * @param message the input message * @param targetClass the target class for the conversion * @param conversionHint an extra object passed to the {@link MessageConverter}, - * e.g. the associated {@code MethodParameter} (may be {@code null}} + * for example, the associated {@code MethodParameter} (may be {@code null}} * @return the result of the conversion, or {@code null} if the converter cannot * perform the conversion * @see #fromMessage(Message, Class) @@ -49,12 +49,12 @@ public interface SmartMessageConverter extends MessageConverter { /** * A variant of {@link #toMessage(Object, MessageHeaders)} which takes an extra - * conversion context as an argument, allowing to take e.g. annotations + * conversion context as an argument, allowing to take, for example, annotations * on a return type into account. * @param payload the Object to convert * @param headers optional headers for the message (may be {@code null}) * @param conversionHint an extra object passed to the {@link MessageConverter}, - * e.g. the associated {@code MethodParameter} (may be {@code null}} + * for example, the associated {@code MethodParameter} (may be {@code null}} * @return the new message, or {@code null} if the converter does not support the * Object type or the target media type * @see #toMessage(Object, MessageHeaders) diff --git a/spring-messaging/src/main/java/org/springframework/messaging/core/AbstractMessageSendingTemplate.java b/spring-messaging/src/main/java/org/springframework/messaging/core/AbstractMessageSendingTemplate.java index f355b343216c..699dabe9e231 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/core/AbstractMessageSendingTemplate.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/core/AbstractMessageSendingTemplate.java @@ -44,7 +44,7 @@ public abstract class AbstractMessageSendingTemplate implements MessageSendin /** * Name of the header that can be set to provide further information - * (e.g. a {@code MethodParameter} instance) about the origin of the + * (for example, a {@code MethodParameter} instance) about the origin of the * payload, to be taken into account as a conversion hint. * @since 4.2 */ diff --git a/spring-messaging/src/main/java/org/springframework/messaging/core/CachingDestinationResolverProxy.java b/spring-messaging/src/main/java/org/springframework/messaging/core/CachingDestinationResolverProxy.java index e98c86ab7bb9..bf83792123f8 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/core/CachingDestinationResolverProxy.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/core/CachingDestinationResolverProxy.java @@ -26,7 +26,7 @@ /** * {@link DestinationResolver} implementation that proxies a target DestinationResolver, * caching its {@link #resolveDestination} results. Such caching is particularly useful - * if the destination resolving process is expensive (e.g. the destination has to be + * if the destination resolving process is expensive (for example, the destination has to be * resolved through an external system) and the resolution results are stable anyway. * * @author Agim Emruli diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/AbstractMessageCondition.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/AbstractMessageCondition.java index 93bb26fcac18..1df91f749549 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/AbstractMessageCondition.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/AbstractMessageCondition.java @@ -61,7 +61,7 @@ public String toString() { /** * Return the collection of objects the message condition is composed of - * (e.g. destination patterns), never {@code null}. + * (for example, destination patterns), never {@code null}. */ protected abstract Collection getContent(); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/CompositeMessageCondition.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/CompositeMessageCondition.java index d9c908fb9284..f9785b9096a2 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/CompositeMessageCondition.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/CompositeMessageCondition.java @@ -98,7 +98,7 @@ public int compareTo(CompositeMessageCondition other, Message message) { checkCompatible(other); List> otherConditions = other.getMessageConditions(); for (int i = 0; i < this.messageConditions.size(); i++) { - int result = compare (this.messageConditions.get(i), otherConditions.get(i), message); + int result = compare(this.messageConditions.get(i), otherConditions.get(i), message); if (result != 0) { return result; } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/DestinationPatternsMessageCondition.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/DestinationPatternsMessageCondition.java index 9c10ea0816c7..bdb6e23dec39 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/DestinationPatternsMessageCondition.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/DestinationPatternsMessageCondition.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -86,7 +86,7 @@ public DestinationPatternsMessageCondition(String[] patterns, RouteMatcher route private static Set prependLeadingSlash(String[] patterns, RouteMatcher routeMatcher) { boolean slashSeparator = routeMatcher.combine("a", "a").equals("a/a"); - Set result = new LinkedHashSet<>(patterns.length); + Set result = CollectionUtils.newLinkedHashSet(patterns.length); for (String pattern : patterns) { if (slashSeparator && StringUtils.hasLength(pattern) && !pattern.startsWith("/")) { pattern = "/" + pattern; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/HandlerMethod.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/HandlerMethod.java index c12f8d6c0c07..7dfd09c6f4dc 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/HandlerMethod.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/HandlerMethod.java @@ -36,7 +36,7 @@ * method annotations, etc. * *

    The class may be created with a bean instance or with a bean name - * (e.g. lazy-init bean, prototype bean). Use {@link #createWithResolvedBean()} + * (for example, lazy-init bean, prototype bean). Use {@link #createWithResolvedBean()} * to obtain a {@code HandlerMethod} instance with a bean instance resolved * through the associated {@link BeanFactory}. * @@ -196,7 +196,8 @@ public String getShortLogMessage() { @Override public boolean equals(@Nullable Object other) { - return (this == other || (super.equals(other) && this.bean.equals(((HandlerMethod) other).bean))); + return (this == other || (super.equals(other) && other instanceof HandlerMethod otherMethod + && this.bean.equals(otherMethod.bean))); } @Override @@ -221,7 +222,7 @@ protected void assertTargetBean(Method method, Object targetBean, Object[] args) String text = "The mapped handler method class '" + methodDeclaringClass.getName() + "' is not an instance of the actual endpoint bean class '" + targetBeanClass.getName() + "'. If the endpoint requires proxying " + - "(e.g. due to @Transactional), please use class-based proxying."; + "(for example, due to @Transactional), please use class-based proxying."; throw new IllegalStateException(formatInvokeError(text, args)); } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/MessageCondition.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/MessageCondition.java index a11bfb119279..e2547f08f778 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/MessageCondition.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/MessageCondition.java @@ -22,7 +22,7 @@ /** * Contract for mapping conditions to messages. * - *

    Message conditions can be combined (e.g. type + method-level conditions), + *

    Message conditions can be combined (for example, type + method-level conditions), * matched to a specific Message, as well as compared to each other in the * context of a Message to determine which one matches a request more closely. * diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/MessageMapping.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/MessageMapping.java index d006ca926934..a5042c9500e9 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/MessageMapping.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/MessageMapping.java @@ -42,7 +42,7 @@ * handled otherwise.

  • *
  • {@link DestinationVariable @DestinationVariable} method argument for * access to template variable values extracted from the message destination, - * e.g. {@code /hotels/{hotel}}. Variable values may also be converted from + * for example, {@code /hotels/{hotel}}. Variable values may also be converted from * String to the declared method argument type, if needed.
  • *
  • {@link Header @Header} method argument to extract a specific message * header value and have a @@ -77,9 +77,9 @@ * *

    Specializations of this annotation include * {@link org.springframework.messaging.simp.annotation.SubscribeMapping @SubscribeMapping} - * (e.g. STOMP subscriptions) and + * (for example, STOMP subscriptions) and * {@link org.springframework.messaging.rsocket.annotation.ConnectMapping @ConnectMapping} - * (e.g. RSocket connections). Both narrow the primary mapping further and also match + * (for example, RSocket connections). Both narrow the primary mapping further and also match * against the message type. Both can be combined with a type-level * {@code @MessageMapping} that declares a common pattern prefix (or prefixes). * @@ -94,7 +94,7 @@ * "Annotated Responders". * * - *

    NOTE: When using controller interfaces (e.g. for AOP proxying), + *

    NOTE: When using controller interfaces (for example, for AOP proxying), * make sure to consistently put all your mapping annotations - such as * {@code @MessageMapping} and {@code @SubscribeMapping} - on * the controller interface rather than on the implementation class. diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/reactive/AbstractNamedValueMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/reactive/AbstractNamedValueMethodArgumentResolver.java index 87da70b87428..1a169e7294d9 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/reactive/AbstractNamedValueMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/reactive/AbstractNamedValueMethodArgumentResolver.java @@ -33,7 +33,7 @@ import org.springframework.util.ClassUtils; /** - * Abstract base class to resolve method arguments from a named value, e.g. + * Abstract base class to resolve method arguments from a named value, for example, * message headers or destination variables. Named values could have one or more * of a name, a required flag, and a default value. * @@ -98,9 +98,9 @@ public Object resolveArgumentValue(MethodParameter parameter, Message message arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); } else if (namedValueInfo.required && !nestedParameter.isOptional()) { - handleMissingValue(namedValueInfo.name, nestedParameter, message); + handleMissingValue(resolvedName.toString(), nestedParameter, message); } - arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType()); + arg = handleNullValue(resolvedName.toString(), arg, nestedParameter.getNestedParameterType()); } else if ("".equals(arg) && namedValueInfo.defaultValue != null) { arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); @@ -114,7 +114,7 @@ else if ("".equals(arg) && namedValueInfo.defaultValue != null) { arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); } else if (namedValueInfo.required && !nestedParameter.isOptional()) { - handleMissingValue(namedValueInfo.name, nestedParameter, message); + handleMissingValue(resolvedName.toString(), nestedParameter, message); } } } 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 c4872d1825f0..850fefbf1759 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 @@ -170,7 +170,7 @@ protected RouteMatcher obtainRouteMatcher() { /** * Configure a {@link ConversionService} to use for type conversion of - * String based values, e.g. in destination variables or headers. + * String based values, for example, in destination variables or headers. *

    By default {@link DefaultFormattingConversionService} is used. * @param conversionService the conversion service to use */ diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java index 5bcb33761dc9..fd17f5e1f7ec 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java @@ -34,7 +34,7 @@ import org.springframework.util.ClassUtils; /** - * Abstract base class to resolve method arguments from a named value, e.g. + * Abstract base class to resolve method arguments from a named value, for example, * message headers or destination variables. Named values could have one or more * of a name, a required flag, and a default value. * @@ -106,9 +106,9 @@ public Object resolveArgument(MethodParameter parameter, Message message) thr arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); } else if (namedValueInfo.required && !nestedParameter.isOptional()) { - handleMissingValue(namedValueInfo.name, nestedParameter, message); + handleMissingValue(resolvedName.toString(), nestedParameter, message); } - arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType()); + arg = handleNullValue(resolvedName.toString(), arg, nestedParameter.getNestedParameterType()); } else if ("".equals(arg) && namedValueInfo.defaultValue != null) { arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); @@ -122,7 +122,7 @@ else if ("".equals(arg) && namedValueInfo.defaultValue != null) { arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); } else if (namedValueInfo.required && !nestedParameter.isOptional()) { - handleMissingValue(namedValueInfo.name, nestedParameter, message); + handleMissingValue(resolvedName.toString(), nestedParameter, message); } } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/MessageMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/MessageMethodArgumentResolver.java index 022d1f3f1a99..46f5c77884f1 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/MessageMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/MessageMethodArgumentResolver.java @@ -99,7 +99,7 @@ public Object resolveArgument(MethodParameter parameter, Message message) thr * Resolve the target class to convert the payload to. *

    By default this is the generic type declared in the {@code Message} * method parameter but that can be overridden to select a more specific - * target type after also taking into account the "Content-Type", e.g. + * target type after also taking into account the "Content-Type", for example, * return {@code String} if target type is {@code Object} and * {@code "Content-Type:text/**"}. * @param parameter the target method parameter diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PayloadMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PayloadMethodArgumentResolver.java index 10014be62df9..e38629fbeeae 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PayloadMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PayloadMethodArgumentResolver.java @@ -186,7 +186,7 @@ else if (payload instanceof Optional optional) { * Resolve the target class to convert the payload to. *

    By default this is simply {@link MethodParameter#getParameterType()} * but that can be overridden to select a more specific target type after - * also taking into account the "Content-Type", e.g. return {@code String} + * also taking into account the "Content-Type", for example, return {@code String} * if target type is {@code Object} and {@code "Content-Type:text/**"}. * @param parameter the target method parameter * @param message the message being processed diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractAsyncReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractAsyncReturnValueHandler.java index 520d3b27bc44..38801e420d80 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractAsyncReturnValueHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractAsyncReturnValueHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +22,7 @@ /** * Convenient base class for {@link AsyncHandlerMethodReturnValueHandler} - * implementations that support only asynchronous (Future-like) return values - * and merely serve as adapters of such types to Spring's - * {@link org.springframework.util.concurrent.ListenableFuture ListenableFuture}. + * implementations that support only asynchronous (Future-like) return values. * * @author Sebastien Deleuze * @since 4.2 diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractExceptionHandlerMethodResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractExceptionHandlerMethodResolver.java index 86d7471d2698..1c5b1593c847 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractExceptionHandlerMethodResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractExceptionHandlerMethodResolver.java @@ -112,7 +112,7 @@ public Method resolveMethod(Throwable exception) { /** * Find a {@link Method} to handle the given exception type. This can be - * useful if an {@link Exception} instance is not available (e.g. for tools). + * useful if an {@link Exception} instance is not available (for example, for tools). *

    Uses {@link ExceptionDepthComparator} if more than one match is found. * @param exceptionType the exception type * @return a Method to handle the exception, or {@code null} if none found diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractMethodMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractMethodMessageHandler.java index b6405818912c..c469fe0db2d5 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractMethodMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractMethodMessageHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -415,7 +415,7 @@ protected Log getHandlerMethodLogger() { /** * Subclasses can invoke this method to populate the MessagingAdviceBean cache - * (e.g. to support "global" {@code @MessageExceptionHandler}). + * (for example, to support "global" {@code @MessageExceptionHandler}). * @since 4.2 */ protected void registerExceptionHandlerAdvice( @@ -522,6 +522,7 @@ protected void handleMessageInternal(Message message, String lookupDestinatio handleMatch(bestMatch.mapping, bestMatch.handlerMethod, lookupDestination, message); } + @SuppressWarnings("NullAway") private void addMatchesToCollection(Collection mappingsToCheck, Message message, List matches) { for (T mapping : mappingsToCheck) { T match = getMatchingMapping(mapping, message); @@ -572,7 +573,7 @@ protected void handleMatch(T mapping, HandlerMethod handlerMethod, String lookup if (returnValue != null && this.returnValueHandlers.isAsyncReturnValue(returnValue, returnType)) { CompletableFuture future = this.returnValueHandlers.toCompletableFuture(returnValue, returnType); if (future != null) { - future.whenComplete(new ReturnValueListenableFutureCallback(invocable, message)); + future.whenComplete(new ReturnValueCallback(invocable, message)); } } else { @@ -703,13 +704,13 @@ public int compare(Match match1, Match match2) { } - private class ReturnValueListenableFutureCallback implements BiConsumer { + private class ReturnValueCallback implements BiConsumer { private final InvocableHandlerMethod handlerMethod; private final Message message; - public ReturnValueListenableFutureCallback(InvocableHandlerMethod handlerMethod, Message message) { + public ReturnValueCallback(InvocableHandlerMethod handlerMethod, Message message) { this.handlerMethod = handlerMethod; this.message = message; } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AsyncHandlerMethodReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AsyncHandlerMethodReturnValueHandler.java index 311969d82c6d..ce41c94c1fc2 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AsyncHandlerMethodReturnValueHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AsyncHandlerMethodReturnValueHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,6 @@ /** * An extension of {@link HandlerMethodReturnValueHandler} for handling async, * Future-like return value types that support success and error callbacks. - * Essentially anything that can be adapted to a - * {@link org.springframework.util.concurrent.ListenableFuture ListenableFuture}. * *

    Implementations should consider extending the convenient base class * {@link AbstractAsyncReturnValueHandler}. @@ -67,10 +65,12 @@ public interface AsyncHandlerMethodReturnValueHandler extends HandlerMethodRetur * @deprecated as of 6.0, in favor of * {@link #toCompletableFuture(Object, MethodParameter)} */ - @Deprecated(since = "6.0") + @Deprecated(since = "6.0", forRemoval = true) + @SuppressWarnings("removal") @Nullable default org.springframework.util.concurrent.ListenableFuture toListenableFuture( Object returnValue, MethodParameter returnType) { + CompletableFuture result = toCompletableFuture(returnValue, returnType); return (result != null ? new org.springframework.util.concurrent.CompletableToListenableFutureAdapter<>(result) : diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java index 40d2ac4ad4a8..cc74ad6ed900 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java @@ -86,7 +86,7 @@ public void setMessageMethodArgumentResolvers(HandlerMethodArgumentResolverCompo /** * Set the ParameterNameDiscoverer for resolving parameter names when needed - * (e.g. default request attribute name). + * (for example, default request attribute name). *

    Default is a {@link org.springframework.core.DefaultParameterNameDiscoverer}. */ public void setParameterNameDiscoverer(ParameterNameDiscoverer parameterNameDiscoverer) { diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/ListenableFutureReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/ListenableFutureReturnValueHandler.java index f2b6c1cf956d..93aeb5952cd8 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/ListenableFutureReturnValueHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/ListenableFutureReturnValueHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,31 +19,31 @@ import java.util.concurrent.CompletableFuture; import org.springframework.core.MethodParameter; -import org.springframework.util.concurrent.ListenableFuture; /** - * Support for {@link ListenableFuture} as a return value type. + * Support for {@link org.springframework.util.concurrent.ListenableFuture} as a return value type. * * @author Sebastien Deleuze * @since 4.2 * @deprecated as of 6.0, in favor of {@link CompletableFutureReturnValueHandler} */ -@Deprecated(since = "6.0") +@Deprecated(since = "6.0", forRemoval = true) +@SuppressWarnings("removal") public class ListenableFutureReturnValueHandler extends AbstractAsyncReturnValueHandler { @Override public boolean supportsReturnType(MethodParameter returnType) { - return ListenableFuture.class.isAssignableFrom(returnType.getParameterType()); + return org.springframework.util.concurrent.ListenableFuture.class.isAssignableFrom(returnType.getParameterType()); } @Override - @SuppressWarnings("unchecked") - public ListenableFuture toListenableFuture(Object returnValue, MethodParameter returnType) { - return (ListenableFuture) returnValue; + public org.springframework.util.concurrent.ListenableFuture toListenableFuture(Object returnValue, MethodParameter returnType) { + return (org.springframework.util.concurrent.ListenableFuture) returnValue; } @Override public CompletableFuture toCompletableFuture(Object returnValue, MethodParameter returnType) { - return ((ListenableFuture) returnValue).completable(); + return ((org.springframework.util.concurrent.ListenableFuture) returnValue).completable(); } + } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java index 6677c252c9ca..c8731bfc49d2 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java @@ -114,7 +114,7 @@ public abstract class AbstractMethodMessageHandler * Configure a predicate for selecting which Spring beans to check for the * presence of message handler methods. *

    This is not set by default. However, subclasses may initialize it to - * some default strategy (e.g. {@code @Controller} classes). + * some default strategy (for example, {@code @Controller} classes). * @see #setHandlers(List) */ public void setHandlerPredicate(@Nullable Predicate> handlerPredicate) { @@ -210,7 +210,7 @@ public String getBeanName() { /** * Subclasses can invoke this method to populate the MessagingAdviceBean cache - * (e.g. to support "global" {@code @MessageExceptionHandler}). + * (for example, to support "global" {@code @MessageExceptionHandler}). */ protected void registerExceptionHandlerAdvice( MessagingAdviceBean bean, AbstractExceptionHandlerMethodResolver resolver) { @@ -227,7 +227,7 @@ public Map getHandlerMethods() { /** * Return a read-only multi-value map with a direct lookup of mappings, - * (e.g. for non-pattern destinations). + * (for example, for non-pattern destinations). */ public MultiValueMap getDestinationLookup() { return CollectionUtils.unmodifiableMultiValueMap(CollectionUtils.toMultiValueMap(this.destinationLookup)); @@ -419,7 +419,7 @@ private HandlerMethod createHandlerMethod(Object handler, Method method) { * This method is invoked just before mappings are added. It allows * subclasses to update the mapping with the {@link HandlerMethod} in mind. * This can be useful when the method signature is used to refine the - * mapping, e.g. based on the cardinality of input and output. + * mapping, for example, based on the cardinality of input and output. *

    By default this method returns the mapping that is passed in. * @param mapping the mapping to be added * @param handlerMethod the target handler for the mapping @@ -505,6 +505,7 @@ private Match getHandlerMethod(Message message) { @Nullable protected abstract RouteMatcher.Route getDestination(Message message); + @SuppressWarnings("NullAway") private void addMatchesToCollection( Collection mappingsToCheck, Message message, List> matches) { @@ -546,7 +547,7 @@ protected void handleNoMatch(@Nullable RouteMatcher.Route destination, Message getResolvers() { /** * Set the ParameterNameDiscoverer for resolving parameter names when needed - * (e.g. default request attribute name). + * (for example, default request attribute name). *

    Default is a {@link DefaultParameterNameDiscoverer}. */ public void setParameterNameDiscoverer(ParameterNameDiscoverer nameDiscoverer) { diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java index 411a295bf588..ccb9c3c198b3 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java @@ -114,7 +114,7 @@ public ReactiveAdapterRegistry getReactiveAdapterRegistry() { } /** - * Method to populate the MessagingAdviceBean cache (e.g. to support "global" + * Method to populate the MessagingAdviceBean cache (for example, to support "global" * {@code @MessageExceptionHandler}). */ public void registerExceptionHandlerAdvice( diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterBuilder.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterBuilder.java index 377700688731..e3a045d7f20a 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterBuilder.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterBuilder.java @@ -245,13 +245,13 @@ private MimeType getDataMimeType(RSocketStrategies strategies) { if (this.dataMimeType != null) { return this.dataMimeType; } - // First non-basic Decoder (e.g. CBOR, Protobuf) + // First non-basic Decoder (for example, CBOR, Protobuf) for (Decoder candidate : strategies.decoders()) { if (!isCoreCodec(candidate) && !candidate.getDecodableMimeTypes().isEmpty()) { return getMimeType(candidate); } } - // First core decoder (e.g. String) + // First core decoder (for example, String) for (Decoder decoder : strategies.decoders()) { if (!decoder.getDecodableMimeTypes().isEmpty()) { return getMimeType(decoder); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequester.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequester.java index 36576f28f905..2c946d5c3137 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequester.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequester.java @@ -90,7 +90,7 @@ public interface RSocketRequester extends Disposable { /** * Begin to specify a new request with the given route to a remote handler. - *

    The route can be a template with placeholders, e.g. + *

    The route can be a template with placeholders, for example, * {@code "flight.{code}"} in which case the supplied route variables are * formatted via {@code toString()} and expanded into the template. * If a formatted variable contains a "." it is replaced with the escape diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/annotation/ConnectMapping.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/annotation/ConnectMapping.java index 8fb2de46c2cf..da56a2b4768c 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/annotation/ConnectMapping.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/annotation/ConnectMapping.java @@ -32,7 +32,7 @@ * {@link org.springframework.messaging.handler.annotation.MessageMapping @MessageMapping} * for a combined route pattern. It supports the same arguments as * {@code @MessageMapping} but the return value must be {@code void}. On a - * server, handling can be asynchronous (e.g. {@code Mono}), in which + * server, handling can be asynchronous (for example, {@code Mono}), in which * case the connection is accepted if and when the {@code Mono} completes. * On the client side this method is only a callback and does not affect the * establishment of the connection. @@ -40,7 +40,7 @@ *

    Note: an {@code @ConnectMapping} method may start * requests to the remote through an * {@link org.springframework.messaging.rsocket.RSocketRequester RSocketRequester} - * method argument, but it must do so independent of the handling thread (e.g. + * method argument, but it must do so independent of the handling thread (for example, * via subscribing on a different thread). * * @author Rossen Stoyanchev diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/annotation/support/RSocketFrameTypeMessageCondition.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/annotation/support/RSocketFrameTypeMessageCondition.java index 96eea1f3c993..e8167a3f1402 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/annotation/support/RSocketFrameTypeMessageCondition.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/annotation/support/RSocketFrameTypeMessageCondition.java @@ -66,7 +66,7 @@ public class RSocketFrameTypeMessageCondition extends AbstractMessageConditionNote that the given handlers do not need to have any stereotype * annotations such as {@code @Controller} which helps to avoid overlap with * server side handlers that may be used in the same application. However, - * for more advanced scenarios, e.g. discovering handlers through a custom + * for more advanced scenarios, for example, discovering handlers through a custom * stereotype annotation, consider declaring {@code RSocketMessageHandler} * as a bean, and then obtain the responder from it. * @param strategies the strategies to set on the created diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/service/PayloadArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/service/PayloadArgumentResolver.java index 8511f675f25f..b80c747bbdc2 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/service/PayloadArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/service/PayloadArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +29,7 @@ * annotated arguments. * * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma * @since 6.0 */ public class PayloadArgumentResolver implements RSocketServiceArgumentResolver { @@ -54,25 +55,28 @@ public boolean resolve( return false; } - if (argument != null) { - ReactiveAdapter reactiveAdapter = this.reactiveAdapterRegistry.getAdapter(parameter.getParameterType()); - if (reactiveAdapter == null) { - requestValues.setPayloadValue(argument); - } - else { - MethodParameter nestedParameter = parameter.nested(); + if (argument == null) { + boolean isOptional = ((annot != null && !annot.required()) || parameter.isOptional()); + Assert.isTrue(isOptional, () -> "Missing payload"); + return true; + } + + ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(parameter.getParameterType()); + if (adapter == null) { + requestValues.setPayloadValue(argument); + } + else { + MethodParameter nestedParameter = parameter.nested(); - String message = "Async type for @Payload should produce value(s)"; - Assert.isTrue(nestedParameter.getNestedParameterType() != Void.class, message); - Assert.isTrue(!reactiveAdapter.isNoValue(), message); + String message = "Async type for @Payload should produce value(s)"; + Assert.isTrue(nestedParameter.getNestedParameterType() != Void.class, message); + Assert.isTrue(!adapter.isNoValue(), message); - requestValues.setPayload( - reactiveAdapter.toPublisher(argument), - ParameterizedTypeReference.forType(nestedParameter.getNestedGenericParameterType())); - } + requestValues.setPayload( + adapter.toPublisher(argument), + ParameterizedTypeReference.forType(nestedParameter.getNestedGenericParameterType())); } return true; } - } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/service/RSocketServiceMethod.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/service/RSocketServiceMethod.java index e4cf46db7ba3..1b6c87f81746 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/service/RSocketServiceMethod.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/service/RSocketServiceMethod.java @@ -92,6 +92,7 @@ private static MethodParameter[] initMethodParameters(Method method) { } @Nullable + @SuppressWarnings("NullAway") private static String initRoute( Method method, Class containingClass, RSocketStrategies strategies, @Nullable StringValueResolver embeddedValueResolver) { diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpAttributes.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpAttributes.java index 31370f4b139a..8946b6db20c6 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpAttributes.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpAttributes.java @@ -28,7 +28,7 @@ /** * A wrapper class for access to attributes associated with a SiMP session - * (e.g. WebSocket session). + * (for example, WebSocket session). * * @author Rossen Stoyanchev * @since 4.1 diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpAttributesContextHolder.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpAttributesContextHolder.java index 7b3f2ce2d2c0..6474d56afce7 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpAttributesContextHolder.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpAttributesContextHolder.java @@ -21,7 +21,7 @@ import org.springframework.messaging.Message; /** - * Holder class to expose SiMP attributes associated with a session (e.g. WebSocket) + * Holder class to expose SiMP attributes associated with a session (for example, WebSocket) * in the form of a thread-bound {@link SimpAttributes} object. * * @author Rossen Stoyanchev diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageHeaderAccessor.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageHeaderAccessor.java index 2fad880906cc..015db5c8dc37 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageHeaderAccessor.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageHeaderAccessor.java @@ -32,7 +32,7 @@ /** * A base class for working with message headers in simple messaging protocols that * support basic messaging patterns. Provides uniform access to specific values common - * across protocols such as a destination, message type (e.g. publish, subscribe, etc), + * across protocols such as a destination, message type (for example, publish, subscribe, etc), * session ID, and others. * *

    Use one of the static factory methods in this class, then call getters and setters, diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageSendingOperations.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageSendingOperations.java index 3f2e4a04c099..e1cac48b59b8 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageSendingOperations.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageSendingOperations.java @@ -55,11 +55,11 @@ public interface SimpMessageSendingOperations extends MessageSendingOperationsBy default headers are interpreted as native headers (e.g. STOMP) and + *

    By default headers are interpreted as native headers (for example, STOMP) and * are saved under a special key in the resulting Spring * {@link org.springframework.messaging.Message Message}. In effect when the * message leaves the application, the provided headers are included with it - * and delivered to the destination (e.g. the STOMP client or broker). + * and delivered to the destination (for example, the STOMP client or broker). *

    If the map already contains the key * {@link org.springframework.messaging.support.NativeMessageHeaderAccessor#NATIVE_HEADERS "nativeHeaders"} * or was prepared with diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpSessionScope.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpSessionScope.java index 1bf7a42a4c2c..e6a4a947161e 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpSessionScope.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpSessionScope.java @@ -22,7 +22,7 @@ /** * A {@link Scope} implementation exposing the attributes of a SiMP session - * (e.g. WebSocket session). + * (for example, WebSocket session). * *

    Relies on a thread-bound {@link SimpAttributes} instance exported by * {@link org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler}. diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/annotation/SubscribeMapping.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/annotation/SubscribeMapping.java index 2e1ac02b67d0..9229e87308c3 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/annotation/SubscribeMapping.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/annotation/SubscribeMapping.java @@ -28,7 +28,7 @@ /** * Annotation for mapping subscription messages onto specific handler methods based * on the destination of a subscription. Supported with STOMP over WebSocket only - * (e.g. STOMP SUBSCRIBE frame). + * (for example, STOMP SUBSCRIBE frame). * *

    This is a method-level annotation that can be combined with a type-level * {@link org.springframework.messaging.handler.annotation.MessageMapping @MessageMapping}. @@ -43,7 +43,7 @@ * user and does not pass through the message broker. This is useful for * implementing a request-reply pattern. * - *

    NOTE: When using controller interfaces (e.g. for AOP proxying), + *

    NOTE: When using controller interfaces (for example, for AOP proxying), * make sure to consistently put all your mapping annotations - such as * {@code @MessageMapping} and {@code @SubscribeMapping} - on * the controller interface rather than on the implementation class. @@ -62,9 +62,9 @@ /** * Destination-based mapping expressed by this annotation. - *

    This is the destination of the STOMP message (e.g. {@code "/positions"}). - * Ant-style path patterns (e.g. {@code "/price.stock.*"}) and path template - * variables (e.g. "/price.stock.{ticker}") are also supported. + *

    This is the destination of the STOMP message (for example, {@code "/positions"}). + * Ant-style path patterns (for example, {@code "/price.stock.*"}) and path template + * variables (for example, "/price.stock.{ticker}") are also supported. */ String[] value() default {}; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/annotation/support/SendToMethodReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/annotation/support/SendToMethodReturnValueHandler.java index 319297e18cb8..44bc35404c5f 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/annotation/support/SendToMethodReturnValueHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/annotation/support/SendToMethodReturnValueHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -68,7 +68,7 @@ public class SendToMethodReturnValueHandler implements HandlerMethodReturnValueH private String defaultUserDestinationPrefix = "/queue"; - private final PropertyPlaceholderHelper placeholderHelper = new PropertyPlaceholderHelper("{", "}", null, false); + private final PropertyPlaceholderHelper placeholderHelper = new PropertyPlaceholderHelper("{", "}", null, null, false); @Nullable private MessageHeaderInitializer headerInitializer; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/annotation/support/SimpAnnotationMethodMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/annotation/support/SimpAnnotationMethodMessageHandler.java index d7955dd4edd2..41ecfb1730e3 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/annotation/support/SimpAnnotationMethodMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/annotation/support/SimpAnnotationMethodMessageHandler.java @@ -131,8 +131,8 @@ public class SimpAnnotationMethodMessageHandler extends AbstractMethodMessageHan /** * Create an instance of SimpAnnotationMethodMessageHandler with the given * message channels and broker messaging template. - * @param clientInboundChannel the channel for receiving messages from clients (e.g. WebSocket clients) - * @param clientOutboundChannel the channel for messages to clients (e.g. WebSocket clients) + * @param clientInboundChannel the channel for receiving messages from clients (for example, WebSocket clients) + * @param clientOutboundChannel the channel for messages to clients (for example, WebSocket clients) * @param brokerTemplate a messaging template to send application messages to the broker */ public SimpAnnotationMethodMessageHandler(SubscribableChannel clientInboundChannel, @@ -159,7 +159,7 @@ public SimpAnnotationMethodMessageHandler(SubscribableChannel clientInboundChann * therefore a slash is automatically appended where missing to ensure a * proper prefix-based match (i.e. matching complete segments). *

    Note however that the remaining portion of a destination after the - * prefix may use a different separator (e.g. commonly "." in messaging) + * prefix may use a different separator (for example, commonly "." in messaging) * depending on the configured {@code PathMatcher}. */ @Override @@ -276,7 +276,8 @@ public MessageHeaderInitializer getHeaderInitializer() { /** * Set the phase that this handler should run in. - *

    By default, this is {@link SmartLifecycle#DEFAULT_PHASE}. + *

    By default, this is {@link SmartLifecycle#DEFAULT_PHASE}, but with + * {@code @EnableWebSocketMessageBroker} configuration it is set to 0. * @since 6.1.4 */ public void setPhase(int phase) { @@ -343,7 +344,7 @@ protected List initArgumentResolvers() { } @Override - @SuppressWarnings("deprecation") + @SuppressWarnings("removal") protected List initReturnValueHandlers() { List handlers = new ArrayList<>(); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/broker/AbstractBrokerMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/broker/AbstractBrokerMessageHandler.java index e71eb4ad29ff..7c17734e0652 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/broker/AbstractBrokerMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/broker/AbstractBrokerMessageHandler.java @@ -87,8 +87,8 @@ public abstract class AbstractBrokerMessageHandler /** * Constructor with no destination prefixes (matches all destinations). - * @param inboundChannel the channel for receiving messages from clients (e.g. WebSocket clients) - * @param outboundChannel the channel for sending messages to clients (e.g. WebSocket clients) + * @param inboundChannel the channel for receiving messages from clients (for example, WebSocket clients) + * @param outboundChannel the channel for sending messages to clients (for example, WebSocket clients) * @param brokerChannel the channel for the application to send messages to the broker */ public AbstractBrokerMessageHandler(SubscribableChannel inboundChannel, MessageChannel outboundChannel, @@ -99,8 +99,8 @@ public AbstractBrokerMessageHandler(SubscribableChannel inboundChannel, MessageC /** * Constructor with destination prefixes to match to destinations of messages. - * @param inboundChannel the channel for receiving messages from clients (e.g. WebSocket clients) - * @param outboundChannel the channel for sending messages to clients (e.g. WebSocket clients) + * @param inboundChannel the channel for receiving messages from clients (for example, WebSocket clients) + * @param outboundChannel the channel for sending messages to clients (for example, WebSocket clients) * @param brokerChannel the channel for the application to send messages to the broker * @param destinationPrefixes prefixes to use to filter out messages */ @@ -202,7 +202,8 @@ public boolean isAutoStartup() { /** * Set the phase that this handler should run in. - *

    By default, this is {@link SmartLifecycle#DEFAULT_PHASE}. + *

    By default, this is {@link SmartLifecycle#DEFAULT_PHASE}, but with + * {@code @EnableWebSocketMessageBroker} configuration it is set to 0. * @since 6.1.4 */ public void setPhase(int phase) { diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/broker/SimpleBrokerMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/broker/SimpleBrokerMessageHandler.java index d6e4fcdb85ee..80bcfe99e473 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/broker/SimpleBrokerMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/broker/SimpleBrokerMessageHandler.java @@ -84,8 +84,8 @@ public class SimpleBrokerMessageHandler extends AbstractBrokerMessageHandler { /** * Create a SimpleBrokerMessageHandler instance with the given message channels * and destination prefixes. - * @param clientInboundChannel the channel for receiving messages from clients (e.g. WebSocket clients) - * @param clientOutboundChannel the channel for sending messages to clients (e.g. WebSocket clients) + * @param clientInboundChannel the channel for receiving messages from clients (for example, WebSocket clients) + * @param clientOutboundChannel the channel for sending messages to clients (for example, WebSocket clients) * @param brokerChannel the channel for the application to send messages to the broker * @param destinationPrefixes prefixes to use to filter out messages */ diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java index b38cfc27d29e..9c505329dc65 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java @@ -21,16 +21,15 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.Executor; import java.util.function.Supplier; import org.springframework.beans.factory.BeanInitializationException; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; -import org.springframework.context.SmartLifecycle; import org.springframework.context.annotation.Bean; import org.springframework.context.event.SmartApplicationListener; -import org.springframework.core.task.TaskExecutor; import org.springframework.lang.Nullable; import org.springframework.messaging.MessageHandler; import org.springframework.messaging.converter.ByteArrayMessageConverter; @@ -92,7 +91,7 @@ * into any application component to send messages. * *

    Subclasses are responsible for the parts of the configuration that feed messages - * to and from the client inbound/outbound channels (e.g. STOMP over WebSocket). + * to and from the client inbound/outbound channels (for example, STOMP over WebSocket). * * @author Rossen Stoyanchev * @author Brian Clozel @@ -158,7 +157,7 @@ public ApplicationContext getApplicationContext() { @Bean public AbstractSubscribableChannel clientInboundChannel( - @Qualifier("clientInboundChannelExecutor") TaskExecutor executor) { + @Qualifier("clientInboundChannelExecutor") Executor executor) { ExecutorSubscribableChannel channel = new ExecutorSubscribableChannel(executor); channel.setLogger(SimpLogging.forLog(channel.getLogger())); @@ -170,9 +169,9 @@ public AbstractSubscribableChannel clientInboundChannel( } @Bean - public TaskExecutor clientInboundChannelExecutor() { + public Executor clientInboundChannelExecutor() { ChannelRegistration registration = getClientInboundChannelRegistration(); - TaskExecutor executor = getTaskExecutor(registration, "clientInboundChannel-", this::defaultTaskExecutor); + Executor executor = getExecutor(registration, "clientInboundChannel-", this::defaultExecutor); if (executor instanceof ExecutorConfigurationSupport executorSupport) { executorSupport.setPhase(getPhase()); } @@ -197,7 +196,7 @@ protected final int getPhase() { } protected int initPhase() { - return SmartLifecycle.DEFAULT_PHASE; + return 0; } /** @@ -209,7 +208,7 @@ protected void configureClientInboundChannel(ChannelRegistration registration) { @Bean public AbstractSubscribableChannel clientOutboundChannel( - @Qualifier("clientOutboundChannelExecutor") TaskExecutor executor) { + @Qualifier("clientOutboundChannelExecutor") Executor executor) { ExecutorSubscribableChannel channel = new ExecutorSubscribableChannel(executor); channel.setLogger(SimpLogging.forLog(channel.getLogger())); @@ -221,9 +220,9 @@ public AbstractSubscribableChannel clientOutboundChannel( } @Bean - public TaskExecutor clientOutboundChannelExecutor() { + public Executor clientOutboundChannelExecutor() { ChannelRegistration registration = getClientOutboundChannelRegistration(); - TaskExecutor executor = getTaskExecutor(registration, "clientOutboundChannel-", this::defaultTaskExecutor); + Executor executor = getExecutor(registration, "clientOutboundChannel-", this::defaultExecutor); if (executor instanceof ExecutorConfigurationSupport executorSupport) { executorSupport.setPhase(getPhase()); } @@ -250,11 +249,11 @@ protected void configureClientOutboundChannel(ChannelRegistration registration) @Bean public AbstractSubscribableChannel brokerChannel( AbstractSubscribableChannel clientInboundChannel, AbstractSubscribableChannel clientOutboundChannel, - @Qualifier("brokerChannelExecutor") TaskExecutor executor) { + @Qualifier("brokerChannelExecutor") Executor executor) { MessageBrokerRegistry registry = getBrokerRegistry(clientInboundChannel, clientOutboundChannel); ChannelRegistration registration = registry.getBrokerChannelRegistration(); - ExecutorSubscribableChannel channel = (registration.hasTaskExecutor() ? + ExecutorSubscribableChannel channel = (registration.hasExecutor() ? new ExecutorSubscribableChannel(executor) : new ExecutorSubscribableChannel()); registration.interceptors(new ImmutableMessageChannelInterceptor()); channel.setLogger(SimpLogging.forLog(channel.getLogger())); @@ -263,18 +262,18 @@ public AbstractSubscribableChannel brokerChannel( } @Bean - public TaskExecutor brokerChannelExecutor( + public Executor brokerChannelExecutor( AbstractSubscribableChannel clientInboundChannel, AbstractSubscribableChannel clientOutboundChannel) { MessageBrokerRegistry registry = getBrokerRegistry(clientInboundChannel, clientOutboundChannel); ChannelRegistration registration = registry.getBrokerChannelRegistration(); - TaskExecutor executor = getTaskExecutor(registration, "brokerChannel-", () -> { + Executor executor = getExecutor(registration, "brokerChannel-", () -> { // Should never be used - ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor(); - threadPoolTaskExecutor.setCorePoolSize(0); - threadPoolTaskExecutor.setMaxPoolSize(1); - threadPoolTaskExecutor.setQueueCapacity(0); - return threadPoolTaskExecutor; + ThreadPoolTaskExecutor fallbackExecutor = new ThreadPoolTaskExecutor(); + fallbackExecutor.setCorePoolSize(0); + fallbackExecutor.setMaxPoolSize(1); + fallbackExecutor.setQueueCapacity(0); + return fallbackExecutor; }); if (executor instanceof ExecutorConfigurationSupport executorSupport) { executorSupport.setPhase(getPhase()); @@ -282,19 +281,19 @@ public TaskExecutor brokerChannelExecutor( return executor; } - private TaskExecutor defaultTaskExecutor() { + private Executor defaultExecutor() { return new TaskExecutorRegistration().getTaskExecutor(); } - private static TaskExecutor getTaskExecutor(ChannelRegistration registration, - String threadNamePrefix, Supplier fallback) { + private static Executor getExecutor(ChannelRegistration registration, + String threadNamePrefix, Supplier fallback) { - return registration.getTaskExecutor(fallback, + return registration.getExecutor(fallback, executor -> setThreadNamePrefix(executor, threadNamePrefix)); } - private static void setThreadNamePrefix(TaskExecutor taskExecutor, String name) { - if (taskExecutor instanceof CustomizableThreadCreator ctc) { + private static void setThreadNamePrefix(Executor executor, String name) { + if (executor instanceof CustomizableThreadCreator ctc) { ctc.setThreadNamePrefix(name); } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/ChannelRegistration.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/ChannelRegistration.java index d2ef4bb1afa9..ad8649587ace 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/ChannelRegistration.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/ChannelRegistration.java @@ -19,10 +19,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.concurrent.Executor; import java.util.function.Consumer; import java.util.function.Supplier; -import org.springframework.core.task.TaskExecutor; import org.springframework.lang.Nullable; import org.springframework.messaging.support.ChannelInterceptor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @@ -41,7 +41,7 @@ public class ChannelRegistration { private TaskExecutorRegistration registration; @Nullable - private TaskExecutor executor; + private Executor executor; private final List interceptors = new ArrayList<>(); @@ -67,14 +67,14 @@ public TaskExecutorRegistration taskExecutor(@Nullable ThreadPoolTaskExecutor ta } /** - * Configure the given {@link TaskExecutor} for this message channel, + * Configure the given {@link Executor} for this message channel, * taking precedence over a {@linkplain #taskExecutor() task executor * registration} if any. - * @param taskExecutor the task executor to use - * @since 6.1.4 + * @param executor the executor to use + * @since 6.2 */ - public ChannelRegistration executor(TaskExecutor taskExecutor) { - this.executor = taskExecutor; + public ChannelRegistration executor(Executor executor) { + this.executor = executor; return this; } @@ -89,7 +89,7 @@ public ChannelRegistration interceptors(ChannelInterceptor... interceptors) { } - protected boolean hasTaskExecutor() { + protected boolean hasExecutor() { return (this.registration != null || this.executor != null); } @@ -98,30 +98,31 @@ protected boolean hasInterceptors() { } /** - * Return the {@link TaskExecutor} to use. If no task executor has been - * configured, the {@code fallback} supplier is used to provide a fallback - * instance. + * Return the {@link Executor} to use. If no executor has been configured, + * the {@code fallback} supplier is used to provide a fallback instance. *

    - * If the {@link TaskExecutor} to use is suitable for further customizations, + * If the {@link Executor} to use is suitable for further customizations, * the {@code customizer} consumer is invoked. - * @param fallback a supplier of a fallback task executor in case none is configured + * @param fallback a supplier of a fallback executor in case none is configured * @param customizer further customizations - * @return the task executor to use - * @since 6.1.4 + * @return the executor to use + * @since 6.2 */ - protected TaskExecutor getTaskExecutor(Supplier fallback, Consumer customizer) { + protected Executor getExecutor(Supplier fallback, Consumer customizer) { if (this.executor != null) { return this.executor; } else if (this.registration != null) { ThreadPoolTaskExecutor registeredTaskExecutor = this.registration.getTaskExecutor(); - customizer.accept(registeredTaskExecutor); + if (!this.registration.isExternallyDefined()) { + customizer.accept(registeredTaskExecutor); + } return registeredTaskExecutor; } else { - TaskExecutor taskExecutor = fallback.get(); - customizer.accept(taskExecutor); - return taskExecutor; + Executor fallbackExecutor = fallback.get(); + customizer.accept(fallbackExecutor); + return fallbackExecutor; } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/MessageBrokerRegistry.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/MessageBrokerRegistry.java index 6ad9a2fa46e6..45caca7de75f 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/MessageBrokerRegistry.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/MessageBrokerRegistry.java @@ -77,7 +77,7 @@ public MessageBrokerRegistry(SubscribableChannel clientInboundChannel, MessageCh /** * Enable a simple message broker and configure one or more prefixes to filter - * destinations targeting the broker (e.g. destinations prefixed with "/topic"). + * destinations targeting the broker (for example, destinations prefixed with "/topic"). */ public SimpleBrokerRegistration enableSimpleBroker(String... destinationPrefixes) { this.simpleBrokerRegistration = new SimpleBrokerRegistration( @@ -127,7 +127,7 @@ protected String getUserRegistryBroadcast() { * Configure one or more prefixes to filter destinations targeting application * annotated methods. For example destinations prefixed with "/app" may be * processed by annotated methods while other destinations may target the - * message broker (e.g. "/topic", "/queue"). + * message broker (for example, "/topic", "/queue"). *

    When messages are processed, the matching prefix is removed from the destination * in order to form the lookup path. This means annotations should not contain the * destination prefix. diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/StompBrokerRelayRegistration.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/StompBrokerRelayRegistration.java index 526c4cf4fd73..417a4dcab50c 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/StompBrokerRelayRegistration.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/StompBrokerRelayRegistration.java @@ -123,7 +123,7 @@ public StompBrokerRelayRegistration setClientPasscode(String passcode) { /** * Set the login for the shared "system" connection used to send messages to * the STOMP broker from within the application, i.e. messages not associated - * with a specific client session (e.g. REST/HTTP request handling method). + * with a specific client session (for example, REST/HTTP request handling method). *

    By default this is set to "guest". */ public StompBrokerRelayRegistration setSystemLogin(String login) { @@ -135,7 +135,7 @@ public StompBrokerRelayRegistration setSystemLogin(String login) { /** * Set the passcode for the shared "system" connection used to send messages to * the STOMP broker from within the application, i.e. messages not associated - * with a specific client session (e.g. REST/HTTP request handling method). + * with a specific client session (for example, REST/HTTP request handling method). *

    By default this is set to "guest". */ public StompBrokerRelayRegistration setSystemPasscode(String passcode) { @@ -194,7 +194,7 @@ public StompBrokerRelayRegistration setTcpClient(TcpOperations tcpClient } /** - * Some STOMP clients (e.g. stomp-js) always send heartbeats at a fixed rate + * Some STOMP clients (for example, stomp-js) always send heartbeats at a fixed rate * but others (Spring STOMP client) do so only when no other messages are * sent. However messages with a non-broker {@link #getDestinationPrefixes() * destination prefix} aren't forwarded and as a result the broker may deem @@ -228,7 +228,7 @@ public StompBrokerRelayRegistration setAutoStartup(boolean autoStartup) { * a chance to try. *

    By default this is not set. * @param destination the destination to broadcast unresolved messages to, - * e.g. "/topic/unresolved-user-destination" + * for example, "/topic/unresolved-user-destination" */ public StompBrokerRelayRegistration setUserDestinationBroadcast(String destination) { this.userDestinationBroadcast = destination; @@ -247,7 +247,7 @@ protected String getUserDestinationBroadcast() { * users connected to other servers. *

    By default this is not set. * @param destination the destination for broadcasting user registry details, - * e.g. "/topic/simp-user-registry". + * for example, "/topic/simp-user-registry". */ public StompBrokerRelayRegistration setUserRegistryBroadcast(String destination) { this.userRegistryBroadcast = destination; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/TaskExecutorRegistration.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/TaskExecutorRegistration.java index f8baf3f2f35f..f9cfdb07ad2a 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/TaskExecutorRegistration.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/TaskExecutorRegistration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +29,8 @@ */ public class TaskExecutorRegistration { + private final boolean externallyDefined; + private final ThreadPoolTaskExecutor taskExecutor; @Nullable @@ -49,6 +51,7 @@ public class TaskExecutorRegistration { * {@link ThreadPoolTaskExecutor}. */ public TaskExecutorRegistration() { + this.externallyDefined = false; this.taskExecutor = new ThreadPoolTaskExecutor(); this.taskExecutor.setCorePoolSize(Runtime.getRuntime().availableProcessors() * 2); this.taskExecutor.setAllowCoreThreadTimeOut(true); @@ -60,6 +63,7 @@ public TaskExecutorRegistration() { * @param taskExecutor the executor to use */ public TaskExecutorRegistration(ThreadPoolTaskExecutor taskExecutor) { + this.externallyDefined = true; Assert.notNull(taskExecutor, "ThreadPoolTaskExecutor must not be null"); this.taskExecutor = taskExecutor; } @@ -122,6 +126,15 @@ public TaskExecutorRegistration queueCapacity(int queueCapacity) { return this; } + /** + * Specify if the task executor has been supplied. + * @return {@code true} if the task executor was provided, {@code false} if + * it has been created internally + * @since 6.2 + */ + protected boolean isExternallyDefined() { + return this.externallyDefined; + } protected ThreadPoolTaskExecutor getTaskExecutor() { if (this.corePoolSize != null) { diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/BufferingStompDecoder.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/BufferingStompDecoder.java index 9f37280ab715..b7c9e1e32238 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/BufferingStompDecoder.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/BufferingStompDecoder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2024 the original author 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,15 +29,13 @@ import org.springframework.util.MultiValueMap; /** - * An extension of {@link org.springframework.messaging.simp.stomp.StompDecoder} - * that buffers content remaining in the input ByteBuffer after the parent - * class has read all (complete) STOMP frames from it. The remaining content - * represents an incomplete STOMP frame. When called repeatedly with additional - * data, the decode method returns one or more messages or, if there is not - * enough data still, continues to buffer. + * Uses {@link org.springframework.messaging.simp.stomp.StompDecoder} to decode + * a {@link ByteBuffer} to one or more STOMP message. If the message is incomplete, + * unused content is buffered and combined with the next input buffer, or if there + * is not enough data still, continues to buffer. * *

    A single instance of this decoder can be invoked repeatedly to read all - * messages from a single stream (e.g. WebSocket session) as long as decoding + * messages from a single stream (for example, WebSocket session) as long as decoding * does not fail. If there is an exception, StompDecoder instance should not * be used any more as its internal state is not guaranteed to be consistent. * It is expected that the underlying session is closed at that point. diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/ConnectionHandlingStompSession.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/ConnectionHandlingStompSession.java index 947b640c2f80..40faa0bcd59d 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/ConnectionHandlingStompSession.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/ConnectionHandlingStompSession.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +35,8 @@ public interface ConnectionHandlingStompSession extends StompSession, StompTcpCo * Return a future that will complete when the session is ready for use. * @deprecated as of 6.0, in favor of {@link #getSession()} */ - @Deprecated(since = "6.0") + @Deprecated(since = "6.0", forRemoval = true) + @SuppressWarnings("removal") default org.springframework.util.concurrent.ListenableFuture getSessionFuture() { return new org.springframework.util.concurrent.CompletableToListenableFutureAdapter<>( getSession()); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/ReactorNettyTcpStompClient.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/ReactorNettyTcpStompClient.java index 5ee2bb2d65bd..d16ab82b3282 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/ReactorNettyTcpStompClient.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/ReactorNettyTcpStompClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -96,9 +96,11 @@ else if (reactorNetty2ClientPresent) { * @return a ListenableFuture for access to the session when ready for use * @deprecated as of 6.0, in favor of {@link #connectAsync(StompSessionHandler)} */ - @Deprecated(since = "6.0") + @Deprecated(since = "6.0", forRemoval = true) + @SuppressWarnings("removal") public org.springframework.util.concurrent.ListenableFuture connect( StompSessionHandler handler) { + return new org.springframework.util.concurrent.CompletableToListenableFutureAdapter<>( connectAsync(handler)); } @@ -122,9 +124,11 @@ public CompletableFuture connectAsync(StompSessionHandler handler) * @return a ListenableFuture for access to the session when ready for use * @deprecated as of 6.0, in favor of {@link #connectAsync(StompHeaders, StompSessionHandler)} */ - @Deprecated(since = "6.0") + @Deprecated(since = "6.0", forRemoval = true) + @SuppressWarnings("removal") public org.springframework.util.concurrent.ListenableFuture connect( @Nullable StompHeaders connectHeaders, StompSessionHandler handler) { + ConnectionHandlingStompSession session = createSession(connectHeaders, handler); this.tcpClient.connectAsync(session); return session.getSessionFuture(); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/SplittingStompEncoder.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/SplittingStompEncoder.java new file mode 100644 index 000000000000..02b3f86422cc --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/SplittingStompEncoder.java @@ -0,0 +1,78 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.messaging.simp.stomp; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.springframework.util.Assert; + +/** + * Uses a {@link StompEncoder} to encode a message and splits it into parts no + * larger than the configured + * {@linkplain #SplittingStompEncoder(StompEncoder, int) buffer size limit}. + * + * @author Injae Kim + * @author Rossen Stoyanchev + * @since 6.2 + * @see StompEncoder + */ +public class SplittingStompEncoder { + + private final StompEncoder encoder; + + private final int bufferSizeLimit; + + + /** + * Create a new {@code SplittingStompEncoder}. + * @param encoder the {@link StompEncoder} to use + * @param bufferSizeLimit the buffer size limit + */ + public SplittingStompEncoder(StompEncoder encoder, int bufferSizeLimit) { + Assert.notNull(encoder, "StompEncoder is required"); + Assert.isTrue(bufferSizeLimit > 0, "Buffer size limit must be greater than 0"); + this.encoder = encoder; + this.bufferSizeLimit = bufferSizeLimit; + } + + + /** + * Encode the given payload and headers to a STOMP frame, and split it into a + * list of parts based on the configured buffer size limit. + * @param headers the STOMP message headers + * @param payload the STOMP message payload + * @return the parts of the encoded STOMP message + */ + public List encode(Map headers, byte[] payload) { + byte[] result = this.encoder.encode(headers, payload); + int length = result.length; + + if (length <= this.bufferSizeLimit) { + return List.of(result); + } + + List frames = new ArrayList<>(); + for (int i = 0; i < length; i += this.bufferSizeLimit) { + frames.add(Arrays.copyOfRange(result, i, Math.min(i + this.bufferSizeLimit, length))); + } + return frames; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompBrokerRelayMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompBrokerRelayMessageHandler.java index 43fcf18d0376..811157d51a28 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompBrokerRelayMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompBrokerRelayMessageHandler.java @@ -157,8 +157,8 @@ public class StompBrokerRelayMessageHandler extends AbstractBrokerMessageHandler /** * Create a StompBrokerRelayMessageHandler instance with the given message channels * and destination prefixes. - * @param inboundChannel the channel for receiving messages from clients (e.g. WebSocket clients) - * @param outboundChannel the channel for sending messages to clients (e.g. WebSocket clients) + * @param inboundChannel the channel for receiving messages from clients (for example, WebSocket clients) + * @param outboundChannel the channel for sending messages to clients (for example, WebSocket clients) * @param brokerChannel the channel for the application to send messages to the broker * @param destinationPrefixes the broker supported destination prefixes; destinations * that do not match the given prefix are ignored. @@ -241,7 +241,7 @@ public String getClientPasscode() { /** * Set the login for the shared "system" connection used to send messages to * the STOMP broker from within the application, i.e. messages not associated - * with a specific client session (e.g. REST/HTTP request handling method). + * with a specific client session (for example, REST/HTTP request handling method). *

    By default this is set to "guest". */ public void setSystemLogin(String systemLogin) { @@ -259,7 +259,7 @@ public String getSystemLogin() { /** * Set the passcode for the shared "system" connection used to send messages to * the STOMP broker from within the application, i.e. messages not associated - * with a specific client session (e.g. REST/HTTP request handling method). + * with a specific client session (for example, REST/HTTP request handling method). *

    By default this is set to "guest". */ public void setSystemPasscode(String systemPasscode) { @@ -317,7 +317,7 @@ public long getSystemHeartbeatReceiveInterval() { * Configure one more destinations to subscribe to on the shared "system" * connection along with MessageHandler's to handle received messages. *

    This is for internal use in a multi-application server scenario where - * servers forward messages to each other (e.g. unresolved user destinations). + * servers forward messages to each other (for example, unresolved user destinations). * @param subscriptions the destinations to subscribe to. */ public void setSystemSubscriptions(@Nullable Map subscriptions) { diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompDecoder.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompDecoder.java index 234d9917e06f..442c4301acc4 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompDecoder.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompDecoder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -78,10 +78,10 @@ public MessageHeaderInitializer getHeaderInitializer() { * Decodes one or more STOMP frames from the given {@code ByteBuffer} into a * list of {@link Message Messages}. If the input buffer contains partial STOMP frame * content, or additional content with a partial STOMP frame, the buffer is - * reset and {@code null} is returned. + * reset, and an empty list is returned. * @param byteBuffer the buffer to decode the STOMP frame from * @return the decoded messages, or an empty list if none - * @throws StompConversionException raised in case of decoding issues + * @throws StompConversionException in case of decoding issues */ public List> decode(ByteBuffer byteBuffer) { return decode(byteBuffer, null); @@ -93,18 +93,18 @@ public List> decode(ByteBuffer byteBuffer) { *

    If the given ByteBuffer contains only partial STOMP frame content and no * complete STOMP frames, an empty list is returned, and the buffer is reset * to where it was. - *

    If the buffer contains one or more STOMP frames, those are returned and - * the buffer reset to point to the beginning of the unused partial content. - *

    The output partialMessageHeaders map is used to store successfully parsed + *

    If the buffer contains one or more STOMP frames, those are returned, and + * the buffer is reset to point to the beginning of the unused partial content. + *

    The {@code partialMessageHeaders} map is used to store successfully parsed * headers in case of partial content. The caller can then check if a * "content-length" header was read, which helps to determine how much more * content is needed before the next attempt to decode. * @param byteBuffer the buffer to decode the STOMP frame from * @param partialMessageHeaders an empty output map that will store the last - * successfully parsed partialMessageHeaders in case of partial message content + * successfully parsed partial message headers in case of partial message content * in cases where the partial buffer ended with a partial STOMP frame * @return the decoded messages, or an empty list if none - * @throws StompConversionException raised in case of decoding issues + * @throws StompConversionException in case of decoding issues */ public List> decode(ByteBuffer byteBuffer, @Nullable MultiValueMap partialMessageHeaders) { @@ -127,9 +127,10 @@ public List> decode(ByteBuffer byteBuffer, } /** - * Decode a single STOMP frame from the given {@code buffer} into a {@link Message}. + * Decode a single STOMP frame from the given {@code byteBuffer} into a {@link Message}. */ @Nullable + @SuppressWarnings("NullAway") private Message decodeMessage(ByteBuffer byteBuffer, @Nullable MultiValueMap headers) { Message decodedMessage = null; skipEol(byteBuffer); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompEncoder.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompEncoder.java index f2537ca55ec4..8d9da03c4252 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompEncoder.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompEncoder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -83,8 +83,8 @@ public byte[] encode(Message message) { /** * Encodes the given payload and headers into a {@code byte[]}. - * @param headers the headers - * @param payload the payload + * @param headers the STOMP message headers + * @param payload the STOMP message payload * @return the encoded message */ public byte[] encode(Map headers, byte[] payload) { diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompHeaderAccessor.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompHeaderAccessor.java index 846ee4e3777d..44fec8110a4f 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompHeaderAccessor.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompHeaderAccessor.java @@ -46,7 +46,7 @@ * {@link org.springframework.messaging.support.NativeMessageHeaderAccessor} * while the parent class {@link SimpMessageHeaderAccessor} manages common * processing headers some of which are based on STOMP headers - * (e.g. destination, content-type, etc). + * (for example, destination, content-type, etc). * *

    An instance of this class can also be created by wrapping an existing * {@code Message}. That message may have been created with the more generic @@ -237,6 +237,7 @@ public boolean isHeartbeat() { return (SimpMessageType.HEARTBEAT == getMessageType()); } + @SuppressWarnings("NullAway") public long[] getHeartbeat() { String rawValue = getFirstNativeHeader(STOMP_HEARTBEAT_HEADER); int pos = (rawValue != null ? rawValue.indexOf(',') : -1); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompHeaders.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompHeaders.java index 381029c3711e..ef253caa5745 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompHeaders.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompHeaders.java @@ -278,6 +278,7 @@ public void setHeartbeat(@Nullable long[] heartbeat) { * Get the heartbeat header. */ @Nullable + @SuppressWarnings("NullAway") public long[] getHeartbeat() { String rawValue = getFirst(HEARTBEAT); int pos = (rawValue != null ? rawValue.indexOf(',') : -1); @@ -514,6 +515,7 @@ public boolean containsValue(Object value) { } @Override + @Nullable public List get(Object key) { return this.headers.get(key); } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/user/DefaultUserDestinationResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/user/DefaultUserDestinationResolver.java index 8ff7a092e994..822864ae09b4 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/user/DefaultUserDestinationResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/user/DefaultUserDestinationResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,20 +30,21 @@ import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessageType; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; /** * A default implementation of {@code UserDestinationResolver} that relies * on a {@link SimpUserRegistry} to find active sessions for a user. * - *

    When a user attempts to subscribe, e.g. to "/user/queue/position-updates", + *

    When a user attempts to subscribe, for example, to "/user/queue/position-updates", * the "/user" prefix is removed and a unique suffix added based on the session - * id, e.g. "/queue/position-updates-useri9oqdfzo" to ensure different users can + * id, for example, "/queue/position-updates-useri9oqdfzo" to ensure different users can * subscribe to the same logical destination without colliding. * - *

    When sending to a user, e.g. "/user/{username}/queue/position-updates", the + *

    When sending to a user, for example, "/user/{username}/queue/position-updates", the * "/user/{username}" prefix is removed and a suffix based on active session id's - * is added, e.g. "/queue/position-updates-useri9oqdfzo". + * is added, for example, "/queue/position-updates-useri9oqdfzo". * * @author Rossen Stoyanchev * @author Brian Clozel @@ -216,7 +217,7 @@ private Set getSessionIdsByUser(String userName, @Nullable String sessio } else { Set sessions = user.getSessions(); - sessionIds = new HashSet<>(sessions.size()); + sessionIds = CollectionUtils.newHashSet(sessions.size()); for (SimpSession session : sessions) { sessionIds.add(session.getId()); } @@ -238,7 +239,7 @@ protected boolean checkDestination(String destination, String requiredPrefix) { * @param sourceDestination the source destination from the input message. * @param actualDestination a subset of the destination without any user prefix. * @param sessionId the id of an active user session, never {@code null}. - * @param user the target user, possibly {@code null}, e.g if not authenticated. + * @param user the target user, possibly {@code null},, for example, if not authenticated. * @return a target destination, or {@code null} if none */ @SuppressWarnings("unused") @@ -282,14 +283,14 @@ public ParseResult(String sourceDest, String actualDest, String subscribeDest, } /** - * The destination from the source message, e.g. "/user/{user}/queue/position-updates". + * The destination from the source message, for example, "/user/{user}/queue/position-updates". */ public String getSourceDestination() { return this.sourceDestination; } /** - * The actual destination, without any user prefix, e.g. "/queue/position-updates". + * The actual destination, without any user prefix, for example, "/queue/position-updates". */ public String getActualDestination() { return this.actualDestination; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/user/MultiServerUserRegistry.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/user/MultiServerUserRegistry.java index b3cda62e5f6d..14d4c21df46f 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/user/MultiServerUserRegistry.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/user/MultiServerUserRegistry.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ public class MultiServerUserRegistry implements SimpUserRegistry, SmartApplicati private final boolean delegateApplicationEvents; - /* Cross-server session lookup (e.g. same user connected to multiple servers) */ + /* Cross-server session lookup (for example, same user connected to multiple servers) */ private final SessionLookup sessionLookup = new SessionLookup(); @@ -278,7 +278,7 @@ private static class TransferSimpUser implements SimpUser { // User sessions from "this" registry only (i.e. one server) private final Set sessions; - // Cross-server session lookup (e.g. user connected to multiple servers) + // Cross-server session lookup (for example, user connected to multiple servers) @Nullable private SessionLookup sessionLookup; @@ -414,7 +414,7 @@ public TransferSimpSession(SimpSession session) { this.id = session.getId(); this.user = new TransferSimpUser(); Set subscriptions = session.getSubscriptions(); - this.subscriptions = new HashSet<>(subscriptions.size()); + this.subscriptions = CollectionUtils.newHashSet(subscriptions.size()); for (SimpSubscription subscription : subscriptions) { this.subscriptions.add(new TransferSimpSubscription(subscription)); } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/user/UserDestinationMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/user/UserDestinationMessageHandler.java index 20cd5e5e479b..791602c0c1fd 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/user/UserDestinationMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/user/UserDestinationMessageHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,7 +20,6 @@ import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.apache.commons.logging.Log; @@ -159,7 +158,8 @@ public MessageHeaderInitializer getHeaderInitializer() { /** * Set the phase that this handler should run in. - *

    By default, this is {@link SmartLifecycle#DEFAULT_PHASE}. + *

    By default, this is {@link SmartLifecycle#DEFAULT_PHASE}, but with + * {@code @EnableWebSocketMessageBroker} configuration it is set to 0. * @since 6.1.4 */ public void setPhase(int phase) { @@ -280,12 +280,10 @@ public MessageSendingOperations getMessagingTemplate() { return this.messagingTemplate; } - public void send(UserDestinationResult destinationResult, Message message) throws MessagingException { - Set sessionIds = destinationResult.getSessionIds(); - Iterator itr = (sessionIds != null ? sessionIds.iterator() : null); - - for (String target : destinationResult.getTargetDestinations()) { - String sessionId = (itr != null ? itr.next() : null); + public void send(UserDestinationResult result, Message message) throws MessagingException { + Iterator itr = result.getSessionIds().iterator(); + for (String target : result.getTargetDestinations()) { + String sessionId = (itr.hasNext() ? itr.next() : null); getTemplateToUse(sessionId).send(target, message); } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/user/UserDestinationResult.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/user/UserDestinationResult.java index 04da7a13f654..2abcbe93e0a0 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/user/UserDestinationResult.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/user/UserDestinationResult.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author 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,7 +44,11 @@ public class UserDestinationResult { private final Set sessionIds; - public UserDestinationResult(String sourceDestination, Set targetDestinations, + /** + * Main constructor. + */ + public UserDestinationResult( + String sourceDestination, Set targetDestinations, String subscribeDestination, @Nullable String user) { this(sourceDestination, targetDestinations, subscribeDestination, user, null); @@ -82,7 +86,7 @@ public String getSourceDestination() { /** * The target destinations that the source destination was translated to, - * one per active user session, e.g. "/queue/position-updates-useri9oqdfzo". + * one per active user session, for example, "/queue/position-updates-useri9oqdfzo". * @return the target destinations, never {@code null} but possibly an empty * set if there are no active sessions for the user. */ @@ -91,7 +95,7 @@ public Set getTargetDestinations() { } /** - * The user destination in the form expected when a client subscribes, e.g. + * The user destination in the form expected when a client subscribes, for example, * "/user/queue/position-updates". * @return the subscribe form of the "user" destination, never {@code null}. */ @@ -114,7 +118,6 @@ public String getUser() { /** * Return the session id for the targetDestination. */ - @Nullable public Set getSessionIds() { return this.sessionIds; } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/support/MessageHeaderAccessor.java b/spring-messaging/src/main/java/org/springframework/messaging/support/MessageHeaderAccessor.java index d452456acab3..ab39f57211b8 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/support/MessageHeaderAccessor.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/support/MessageHeaderAccessor.java @@ -123,12 +123,16 @@ public class MessageHeaderAccessor { @Nullable private IdGenerator idGenerator; + private MessageHeaderAccessor(@Nullable MessageHeaders headers) { + this.headers = new MutableMessageHeaders(headers); + } + /** * A constructor to create new headers. */ public MessageHeaderAccessor() { - this(null); + this((MessageHeaders) null); } /** @@ -136,7 +140,26 @@ public MessageHeaderAccessor() { * @param message a message to copy the headers from, or {@code null} if none */ public MessageHeaderAccessor(@Nullable Message message) { - this.headers = new MutableMessageHeaders(message != null ? message.getHeaders() : null); + this(message != null ? message.getHeaders() : null); + } + + + /** + * Create an instance from a plain {@link Map}. + * @param map the raw headers + * @since 6.2 + */ + public static MessageHeaderAccessor fromMap(@Nullable Map map) { + return fromMessageHeaders(new MessageHeaders(map)); + } + + /** + * Create an instance from an existing {@link MessageHeaders} instance. + * @param headers the headers + * @since 6.2 + */ + public static MessageHeaderAccessor fromMessageHeaders(@Nullable MessageHeaders headers) { + return new MessageHeaderAccessor(headers); } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/tcp/TcpConnection.java b/spring-messaging/src/main/java/org/springframework/messaging/tcp/TcpConnection.java index d6eda9dbb97a..545b1f3d1218 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/tcp/TcpConnection.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/tcp/TcpConnection.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +37,8 @@ public interface TcpConnection

    extends Closeable { * message was successfully sent * @deprecated as of 6.0, in favor of {@link #sendAsync(Message)} */ - @Deprecated(since = "6.0") + @Deprecated(since = "6.0", forRemoval = true) + @SuppressWarnings("removal") default org.springframework.util.concurrent.ListenableFuture send(Message

    message) { return new org.springframework.util.concurrent.CompletableToListenableFutureAdapter<>( sendAsync(message)); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/tcp/TcpOperations.java b/spring-messaging/src/main/java/org/springframework/messaging/tcp/TcpOperations.java index 4a4849d177c8..6ee521358da9 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/tcp/TcpOperations.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/tcp/TcpOperations.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +34,11 @@ public interface TcpOperations

    { * connection is successfully established * @deprecated as of 6.0, in favor of {@link #connectAsync(TcpConnectionHandler)} */ - @Deprecated(since = "6.0") + @Deprecated(since = "6.0", forRemoval = true) + @SuppressWarnings("removal") default org.springframework.util.concurrent.ListenableFuture connect( TcpConnectionHandler

    connectionHandler) { + return new org.springframework.util.concurrent.CompletableToListenableFutureAdapter<>( connectAsync(connectionHandler)); } @@ -58,9 +60,11 @@ default org.springframework.util.concurrent.ListenableFuture connect( * initial connection is successfully established * @deprecated as of 6.0, in favor of {@link #connectAsync(TcpConnectionHandler, ReconnectStrategy)} */ - @Deprecated(since = "6.0") + @Deprecated(since = "6.0", forRemoval = true) + @SuppressWarnings("removal") default org.springframework.util.concurrent.ListenableFuture connect( TcpConnectionHandler

    connectionHandler, ReconnectStrategy reconnectStrategy) { + return new org.springframework.util.concurrent.CompletableToListenableFutureAdapter<>( connectAsync(connectionHandler, reconnectStrategy)); } @@ -81,10 +85,10 @@ default org.springframework.util.concurrent.ListenableFuture connect( * connection is successfully closed * @deprecated as of 6.0, in favor of {@link #shutdownAsync()} */ - @Deprecated(since = "6.0") + @Deprecated(since = "6.0", forRemoval = true) + @SuppressWarnings("removal") default org.springframework.util.concurrent.ListenableFuture shutdown() { - return new org.springframework.util.concurrent.CompletableToListenableFutureAdapter<>( - shutdownAsync()); + return new org.springframework.util.concurrent.CompletableToListenableFutureAdapter<>(shutdownAsync()); } /** diff --git a/spring-messaging/src/main/java/org/springframework/messaging/tcp/reactor/TcpMessageCodec.java b/spring-messaging/src/main/java/org/springframework/messaging/tcp/reactor/TcpMessageCodec.java index 8cae457de132..732e8237447b 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/tcp/reactor/TcpMessageCodec.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/tcp/reactor/TcpMessageCodec.java @@ -23,7 +23,7 @@ /** * Contract to encode and decode a {@link Message} to and from a {@link ByteBuffer} - * allowing a higher-level protocol (e.g. STOMP over TCP) to plug in. + * allowing a higher-level protocol (for example, STOMP over TCP) to plug in. * * @author Rossen Stoyanchev * @since 6.0 diff --git a/spring-messaging/src/test/java/org/springframework/messaging/core/DestinationResolvingMessagingTemplateTests.java b/spring-messaging/src/test/java/org/springframework/messaging/core/DestinationResolvingMessagingTemplateTests.java index 6a3f8b4c084a..4fb1006d1ddf 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/core/DestinationResolvingMessagingTemplateTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/core/DestinationResolvingMessagingTemplateTests.java @@ -77,7 +77,7 @@ void send() { void sendNoDestinationResolver() { TestDestinationResolvingMessagingTemplate template = new TestDestinationResolvingMessagingTemplate(); assertThatIllegalStateException().isThrownBy(() -> - template.send("myChannel", new GenericMessage("payload"))); + template.send("myChannel", new GenericMessage<>("payload"))); } @Test diff --git a/spring-messaging/src/test/java/org/springframework/messaging/core/MessageRequestReplyTemplateTests.java b/spring-messaging/src/test/java/org/springframework/messaging/core/MessageRequestReplyTemplateTests.java index 272af387c737..725dc9cb8293 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/core/MessageRequestReplyTemplateTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/core/MessageRequestReplyTemplateTests.java @@ -67,7 +67,7 @@ void sendAndReceive() { @Test void sendAndReceiveMissingDestination() { assertThatIllegalStateException().isThrownBy(() -> - this.template.sendAndReceive(new GenericMessage("request"))); + this.template.sendAndReceive(new GenericMessage<>("request"))); } @Test diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/reactive/HeaderMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/reactive/HeaderMethodArgumentResolverTests.java index 940d9a97f652..43f4260e66cf 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/reactive/HeaderMethodArgumentResolverTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/reactive/HeaderMethodArgumentResolverTests.java @@ -36,11 +36,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.springframework.messaging.handler.annotation.MessagingPredicates.header; import static org.springframework.messaging.handler.annotation.MessagingPredicates.headerPlain; /** * Test fixture for {@link HeaderMethodArgumentResolver} tests. + * * @author Rossen Stoyanchev */ class HeaderMethodArgumentResolverTests { @@ -109,8 +111,8 @@ void resolveArgumentDefaultValue() { @Test void resolveDefaultValueSystemProperty() { - System.setProperty("systemProperty", "sysbar"); try { + System.setProperty("systemProperty", "sysbar"); Message message = MessageBuilder.withPayload(new byte[0]).build(); MethodParameter param = this.resolvable.annot(header("name", "#{systemProperties.systemProperty}")).arg(); Object result = resolveArgument(param, message); @@ -123,8 +125,8 @@ void resolveDefaultValueSystemProperty() { @Test void resolveNameFromSystemProperty() { - System.setProperty("systemProperty", "sysbar"); try { + System.setProperty("systemProperty", "sysbar"); Message message = MessageBuilder.withPayload(new byte[0]).setHeader("sysbar", "foo").build(); MethodParameter param = this.resolvable.annot(header("#{systemProperties.systemProperty}")).arg(); Object result = resolveArgument(param, message); @@ -151,6 +153,41 @@ void resolveOptionalHeaderAsEmpty() { assertThat(result).isEqualTo(Optional.empty()); } + @Test + void missingParameterFromSystemPropertyThroughPlaceholder() { + try { + String expected = "sysbar"; + System.setProperty("systemProperty", expected); + Message message = MessageBuilder.withPayload(new byte[0]).build(); + MethodParameter param = this.resolvable.annot(header("#{systemProperties.systemProperty}")).arg(); + + assertThatExceptionOfType(MessageHandlingException.class) + .isThrownBy(() -> resolveArgument(param, message)) + .withMessageContaining(expected); + } + finally { + System.clearProperty("systemProperty"); + } + } + + @Test + void notNullablePrimitiveParameterFromSystemPropertyThroughPlaceholder() { + try { + String expected = "sysbar"; + System.setProperty("systemProperty", expected); + Message message = MessageBuilder.withPayload(new byte[0]).build(); + MethodParameter param = this.resolvable.annot(header("${systemProperty}").required(false)).arg(); + + assertThatIllegalStateException() + .isThrownBy(() -> resolver.resolveArgument(param, message)) + .withMessageContaining(expected); + } + finally { + System.clearProperty("systemProperty"); + } + } + + @SuppressWarnings({"unchecked", "ConstantConditions"}) private T resolveArgument(MethodParameter param, Message message) { return (T) this.resolver.resolveArgument(param, message).block(Duration.ofSeconds(5)); @@ -165,7 +202,8 @@ public void handleMessage( @Header(name = "#{systemProperties.systemProperty}") String param4, String param5, @Header("foo") Optional param6, - @Header("nativeHeaders.param1") String nativeHeaderParam1) { + @Header("nativeHeaders.param1") String nativeHeaderParam1, + @Header(name = "${systemProperty}", required = false) int primitivePlaceholderParam) { } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolverTests.java index 713d9600fd83..7c0271f45a3a 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolverTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolverTests.java @@ -35,6 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.springframework.messaging.handler.annotation.MessagingPredicates.header; import static org.springframework.messaging.handler.annotation.MessagingPredicates.headerPlain; @@ -111,8 +112,8 @@ void resolveArgumentDefaultValue() throws Exception { @Test void resolveDefaultValueSystemProperty() throws Exception { - System.setProperty("systemProperty", "sysbar"); try { + System.setProperty("systemProperty", "sysbar"); Message message = MessageBuilder.withPayload(new byte[0]).build(); MethodParameter param = this.resolvable.annot(header("name", "#{systemProperties.systemProperty}")).arg(); Object result = resolver.resolveArgument(param, message); @@ -125,8 +126,8 @@ void resolveDefaultValueSystemProperty() throws Exception { @Test void resolveNameFromSystemProperty() throws Exception { - System.setProperty("systemProperty", "sysbar"); try { + System.setProperty("systemProperty", "sysbar"); Message message = MessageBuilder.withPayload(new byte[0]).setHeader("sysbar", "foo").build(); MethodParameter param = this.resolvable.annot(header("#{systemProperties.systemProperty}")).arg(); Object result = resolver.resolveArgument(param, message); @@ -137,6 +138,40 @@ void resolveNameFromSystemProperty() throws Exception { } } + @Test + void missingParameterFromSystemPropertyThroughPlaceholder() { + try { + String expected = "sysbar"; + System.setProperty("systemProperty", expected); + Message message = MessageBuilder.withPayload(new byte[0]).build(); + MethodParameter param = this.resolvable.annot(header("#{systemProperties.systemProperty}")).arg(); + + assertThatExceptionOfType(MessageHandlingException.class) + .isThrownBy(() -> resolver.resolveArgument(param, message)) + .withMessageContaining(expected); + } + finally { + System.clearProperty("systemProperty"); + } + } + + @Test + void notNullablePrimitiveParameterFromSystemPropertyThroughPlaceholder() { + try { + String expected = "sysbar"; + System.setProperty("systemProperty", expected); + Message message = MessageBuilder.withPayload(new byte[0]).build(); + MethodParameter param = this.resolvable.annot(header("${systemProperty}").required(false)).arg(); + + assertThatIllegalStateException() + .isThrownBy(() -> resolver.resolveArgument(param, message)) + .withMessageContaining(expected); + } + finally { + System.clearProperty("systemProperty"); + } + } + @Test void resolveOptionalHeaderWithValue() throws Exception { Message message = MessageBuilder.withPayload("foo").setHeader("foo", "bar").build(); @@ -162,7 +197,8 @@ public void handleMessage( @Header(name = "#{systemProperties.systemProperty}") String param4, String param5, @Header("foo") Optional param6, - @Header("nativeHeaders.param1") String nativeHeaderParam1) { + @Header("nativeHeaders.param1") String nativeHeaderParam1, + @Header(name = "${systemProperty}", required = false) int primitivePlaceholderParam) { } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/protobuf/Msg.java b/spring-messaging/src/test/java/org/springframework/messaging/protobuf/Msg.java index e953f32ce3be..a7e43397b437 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/protobuf/Msg.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/protobuf/Msg.java @@ -1,153 +1,71 @@ -/* - * Copyright 2002-2022 the original author or authors. - * - * Licensed under the Apache License, Version 2.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. - */ - // Generated by the protocol buffer compiler. DO NOT EDIT! +// NO CHECKED-IN PROTOBUF GENCODE // source: sample.proto +// Protobuf Java Version: 4.27.0 package org.springframework.messaging.protobuf; /** * Protobuf type {@code Msg} */ -@SuppressWarnings("serial") -public final class Msg extends - com.google.protobuf.GeneratedMessage - implements MsgOrBuilder { +public final class Msg extends + com.google.protobuf.GeneratedMessage implements + // @@protoc_insertion_point(message_implements:Msg) + MsgOrBuilder { +private static final long serialVersionUID = 0L; + static { + com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion( + com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, + /* major= */ 4, + /* minor= */ 27, + /* patch= */ 0, + /* suffix= */ "", + Msg.class.getName()); + } // Use Msg.newBuilder() to construct. private Msg(com.google.protobuf.GeneratedMessage.Builder builder) { super(builder); - this.unknownFields = builder.getUnknownFields(); } - private Msg(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } - - private static final Msg defaultInstance; - public static Msg getDefaultInstance() { - return defaultInstance; - } - - public Msg getDefaultInstanceForType() { - return defaultInstance; + private Msg() { + foo_ = ""; } - private final com.google.protobuf.UnknownFieldSet unknownFields; - @java.lang.Override - public final com.google.protobuf.UnknownFieldSet - getUnknownFields() { - return this.unknownFields; - } - private Msg( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - initFields(); - @SuppressWarnings("unused") - int mutable_bitField0_ = 0; - com.google.protobuf.UnknownFieldSet.Builder unknownFields = - com.google.protobuf.UnknownFieldSet.newBuilder(); - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - default: { - if (!parseUnknownField(input, unknownFields, - extensionRegistry, tag)) { - done = true; - } - break; - } - case 10: { - bitField0_ |= 0x00000001; - foo_ = input.readBytes(); - break; - } - case 18: { - SecondMsg.Builder subBuilder = null; - if (((bitField0_ & 0x00000002) == 0x00000002)) { - subBuilder = blah_.toBuilder(); - } - blah_ = input.readMessage(SecondMsg.PARSER, extensionRegistry); - if (subBuilder != null) { - subBuilder.mergeFrom(blah_); - blah_ = subBuilder.buildPartial(); - } - bitField0_ |= 0x00000002; - break; - } - } - } - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(this); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException( - e.getMessage()).setUnfinishedMessage(this); - } finally { - this.unknownFields = unknownFields.build(); - makeExtensionsImmutable(); - } - } public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return OuterSample.internal_static_Msg_descriptor; + return org.springframework.messaging.protobuf.OuterSample.internal_static_Msg_descriptor; } + @java.lang.Override protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return OuterSample.internal_static_Msg_fieldAccessorTable + return org.springframework.messaging.protobuf.OuterSample.internal_static_Msg_fieldAccessorTable .ensureFieldAccessorsInitialized( - Msg.class, Msg.Builder.class); - } - - public static com.google.protobuf.Parser PARSER = - new com.google.protobuf.AbstractParser() { - public Msg parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return new Msg(input, extensionRegistry); - } - }; - - @java.lang.Override - public com.google.protobuf.Parser getParserForType() { - return PARSER; + org.springframework.messaging.protobuf.Msg.class, org.springframework.messaging.protobuf.Msg.Builder.class); } private int bitField0_; - // optional string foo = 1; public static final int FOO_FIELD_NUMBER = 1; - private java.lang.Object foo_; + @SuppressWarnings("serial") + private volatile java.lang.Object foo_ = ""; /** * optional string foo = 1; + * @return Whether the foo field is set. */ + @java.lang.Override public boolean hasFoo() { - return ((bitField0_ & 0x00000001) == 0x00000001); + return ((bitField0_ & 0x00000001) != 0); } /** * optional string foo = 1; + * @return The foo. */ + @java.lang.Override public java.lang.String getFoo() { java.lang.Object ref = foo_; if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); if (bs.isValidUtf8()) { @@ -158,12 +76,14 @@ public java.lang.String getFoo() { } /** * optional string foo = 1; + * @return The bytes for foo. */ + @java.lang.Override public com.google.protobuf.ByteString getFooBytes() { java.lang.Object ref = foo_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); foo_ = b; @@ -173,138 +93,202 @@ public java.lang.String getFoo() { } } - // optional .SecondMsg blah = 2; public static final int BLAH_FIELD_NUMBER = 2; - private SecondMsg blah_; + private org.springframework.messaging.protobuf.SecondMsg blah_; /** * optional .SecondMsg blah = 2; + * @return Whether the blah field is set. */ + @java.lang.Override public boolean hasBlah() { - return ((bitField0_ & 0x00000002) == 0x00000002); + return ((bitField0_ & 0x00000002) != 0); } /** * optional .SecondMsg blah = 2; + * @return The blah. */ - public SecondMsg getBlah() { - return blah_; + @java.lang.Override + public org.springframework.messaging.protobuf.SecondMsg getBlah() { + return blah_ == null ? org.springframework.messaging.protobuf.SecondMsg.getDefaultInstance() : blah_; } /** * optional .SecondMsg blah = 2; */ - public SecondMsgOrBuilder getBlahOrBuilder() { - return blah_; + @java.lang.Override + public org.springframework.messaging.protobuf.SecondMsgOrBuilder getBlahOrBuilder() { + return blah_ == null ? org.springframework.messaging.protobuf.SecondMsg.getDefaultInstance() : blah_; } - private void initFields() { - foo_ = ""; - blah_ = SecondMsg.getDefaultInstance(); - } private byte memoizedIsInitialized = -1; + @java.lang.Override public final boolean isInitialized() { byte isInitialized = memoizedIsInitialized; - if (isInitialized != -1) return isInitialized == 1; + if (isInitialized == 1) return true; + if (isInitialized == 0) return false; memoizedIsInitialized = 1; return true; } + @java.lang.Override public void writeTo(com.google.protobuf.CodedOutputStream output) throws java.io.IOException { - getSerializedSize(); - if (((bitField0_ & 0x00000001) == 0x00000001)) { - output.writeBytes(1, getFooBytes()); + if (((bitField0_ & 0x00000001) != 0)) { + com.google.protobuf.GeneratedMessage.writeString(output, 1, foo_); } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - output.writeMessage(2, blah_); + if (((bitField0_ & 0x00000002) != 0)) { + output.writeMessage(2, getBlah()); } getUnknownFields().writeTo(output); } - private int memoizedSerializedSize = -1; + @java.lang.Override public int getSerializedSize() { - int size = memoizedSerializedSize; + int size = memoizedSize; if (size != -1) return size; size = 0; - if (((bitField0_ & 0x00000001) == 0x00000001)) { - size += com.google.protobuf.CodedOutputStream - .computeBytesSize(1, getFooBytes()); + if (((bitField0_ & 0x00000001) != 0)) { + size += com.google.protobuf.GeneratedMessage.computeStringSize(1, foo_); } - if (((bitField0_ & 0x00000002) == 0x00000002)) { + if (((bitField0_ & 0x00000002) != 0)) { size += com.google.protobuf.CodedOutputStream - .computeMessageSize(2, blah_); + .computeMessageSize(2, getBlah()); } size += getUnknownFields().getSerializedSize(); - memoizedSerializedSize = size; + memoizedSize = size; return size; } - private static final long serialVersionUID = 0L; @java.lang.Override - protected java.lang.Object writeReplace() - throws java.io.ObjectStreamException { - return super.writeReplace(); + public boolean equals(final java.lang.Object obj) { + if (obj == this) { + return true; + } + if (!(obj instanceof org.springframework.messaging.protobuf.Msg)) { + return super.equals(obj); + } + org.springframework.messaging.protobuf.Msg other = (org.springframework.messaging.protobuf.Msg) obj; + + if (hasFoo() != other.hasFoo()) return false; + if (hasFoo()) { + if (!getFoo() + .equals(other.getFoo())) return false; + } + if (hasBlah() != other.hasBlah()) return false; + if (hasBlah()) { + if (!getBlah() + .equals(other.getBlah())) return false; + } + if (!getUnknownFields().equals(other.getUnknownFields())) return false; + return true; } - public static Msg parseFrom( + @java.lang.Override + public int hashCode() { + if (memoizedHashCode != 0) { + return memoizedHashCode; + } + int hash = 41; + hash = (19 * hash) + getDescriptor().hashCode(); + if (hasFoo()) { + hash = (37 * hash) + FOO_FIELD_NUMBER; + hash = (53 * hash) + getFoo().hashCode(); + } + if (hasBlah()) { + hash = (37 * hash) + BLAH_FIELD_NUMBER; + hash = (53 * hash) + getBlah().hashCode(); + } + hash = (29 * hash) + getUnknownFields().hashCode(); + memoizedHashCode = hash; + return hash; + } + + public static org.springframework.messaging.protobuf.Msg parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static org.springframework.messaging.protobuf.Msg parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static org.springframework.messaging.protobuf.Msg parseFrom( com.google.protobuf.ByteString data) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data); } - public static Msg parseFrom( + public static org.springframework.messaging.protobuf.Msg parseFrom( com.google.protobuf.ByteString data, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data, extensionRegistry); } - public static Msg parseFrom(byte[] data) + public static org.springframework.messaging.protobuf.Msg parseFrom(byte[] data) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data); } - public static Msg parseFrom( + public static org.springframework.messaging.protobuf.Msg parseFrom( byte[] data, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data, extensionRegistry); } - public static Msg parseFrom(java.io.InputStream input) + public static org.springframework.messaging.protobuf.Msg parseFrom(java.io.InputStream input) throws java.io.IOException { - return PARSER.parseFrom(input); + return com.google.protobuf.GeneratedMessage + .parseWithIOException(PARSER, input); } - public static Msg parseFrom( + public static org.springframework.messaging.protobuf.Msg parseFrom( java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); + return com.google.protobuf.GeneratedMessage + .parseWithIOException(PARSER, input, extensionRegistry); } - public static Msg parseDelimitedFrom(java.io.InputStream input) + + public static org.springframework.messaging.protobuf.Msg parseDelimitedFrom(java.io.InputStream input) throws java.io.IOException { - return PARSER.parseDelimitedFrom(input); + return com.google.protobuf.GeneratedMessage + .parseDelimitedWithIOException(PARSER, input); } - public static Msg parseDelimitedFrom( + + public static org.springframework.messaging.protobuf.Msg parseDelimitedFrom( java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - return PARSER.parseDelimitedFrom(input, extensionRegistry); + return com.google.protobuf.GeneratedMessage + .parseDelimitedWithIOException(PARSER, input, extensionRegistry); } - public static Msg parseFrom( + public static org.springframework.messaging.protobuf.Msg parseFrom( com.google.protobuf.CodedInputStream input) throws java.io.IOException { - return PARSER.parseFrom(input); + return com.google.protobuf.GeneratedMessage + .parseWithIOException(PARSER, input); } - public static Msg parseFrom( + public static org.springframework.messaging.protobuf.Msg parseFrom( com.google.protobuf.CodedInputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); + return com.google.protobuf.GeneratedMessage + .parseWithIOException(PARSER, input, extensionRegistry); } - public static Builder newBuilder() { return Builder.create(); } + @java.lang.Override public Builder newBuilderForType() { return newBuilder(); } - public static Builder newBuilder(Msg prototype) { - return newBuilder().mergeFrom(prototype); + public static Builder newBuilder() { + return DEFAULT_INSTANCE.toBuilder(); + } + public static Builder newBuilder(org.springframework.messaging.protobuf.Msg prototype) { + return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); + } + @java.lang.Override + public Builder toBuilder() { + return this == DEFAULT_INSTANCE + ? new Builder() : new Builder().mergeFrom(this); } - public Builder toBuilder() { return newBuilder(this); } @java.lang.Override protected Builder newBuilderForType( @@ -316,18 +300,20 @@ protected Builder newBuilderForType( * Protobuf type {@code Msg} */ public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder - implements MsgOrBuilder { + com.google.protobuf.GeneratedMessage.Builder implements + // @@protoc_insertion_point(builder_implements:Msg) + org.springframework.messaging.protobuf.MsgOrBuilder { public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return OuterSample.internal_static_Msg_descriptor; + return org.springframework.messaging.protobuf.OuterSample.internal_static_Msg_descriptor; } + @java.lang.Override protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return OuterSample.internal_static_Msg_fieldAccessorTable + return org.springframework.messaging.protobuf.OuterSample.internal_static_Msg_fieldAccessorTable .ensureFieldAccessorsInitialized( - Msg.class, Msg.Builder.class); + org.springframework.messaging.protobuf.Msg.class, org.springframework.messaging.protobuf.Msg.Builder.class); } // Construct using org.springframework.messaging.protobuf.Msg.newBuilder() @@ -341,132 +327,164 @@ private Builder( maybeForceBuilderInitialization(); } private void maybeForceBuilderInitialization() { - if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { + if (com.google.protobuf.GeneratedMessage + .alwaysUseFieldBuilders) { getBlahFieldBuilder(); } } - private static Builder create() { - return new Builder(); - } - + @java.lang.Override public Builder clear() { super.clear(); + bitField0_ = 0; foo_ = ""; - bitField0_ = (bitField0_ & ~0x00000001); - if (blahBuilder_ == null) { - blah_ = SecondMsg.getDefaultInstance(); - } else { - blahBuilder_.clear(); + blah_ = null; + if (blahBuilder_ != null) { + blahBuilder_.dispose(); + blahBuilder_ = null; } - bitField0_ = (bitField0_ & ~0x00000002); return this; } - public Builder clone() { - return create().mergeFrom(buildPartial()); - } - + @java.lang.Override public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() { - return OuterSample.internal_static_Msg_descriptor; + return org.springframework.messaging.protobuf.OuterSample.internal_static_Msg_descriptor; } - public Msg getDefaultInstanceForType() { - return Msg.getDefaultInstance(); + @java.lang.Override + public org.springframework.messaging.protobuf.Msg getDefaultInstanceForType() { + return org.springframework.messaging.protobuf.Msg.getDefaultInstance(); } - public Msg build() { - Msg result = buildPartial(); + @java.lang.Override + public org.springframework.messaging.protobuf.Msg build() { + org.springframework.messaging.protobuf.Msg result = buildPartial(); if (!result.isInitialized()) { throw newUninitializedMessageException(result); } return result; } - public Msg buildPartial() { - Msg result = new Msg(this); + @java.lang.Override + public org.springframework.messaging.protobuf.Msg buildPartial() { + org.springframework.messaging.protobuf.Msg result = new org.springframework.messaging.protobuf.Msg(this); + if (bitField0_ != 0) { buildPartial0(result); } + onBuilt(); + return result; + } + + private void buildPartial0(org.springframework.messaging.protobuf.Msg result) { int from_bitField0_ = bitField0_; int to_bitField0_ = 0; - if (((from_bitField0_ & 0x00000001) == 0x00000001)) { + if (((from_bitField0_ & 0x00000001) != 0)) { + result.foo_ = foo_; to_bitField0_ |= 0x00000001; } - result.foo_ = foo_; - if (((from_bitField0_ & 0x00000002) == 0x00000002)) { + if (((from_bitField0_ & 0x00000002) != 0)) { + result.blah_ = blahBuilder_ == null + ? blah_ + : blahBuilder_.build(); to_bitField0_ |= 0x00000002; } - if (blahBuilder_ == null) { - result.blah_ = blah_; - } else { - result.blah_ = blahBuilder_.build(); - } - result.bitField0_ = to_bitField0_; - onBuilt(); - return result; + result.bitField0_ |= to_bitField0_; } + @java.lang.Override public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof Msg) { - return mergeFrom((Msg)other); + if (other instanceof org.springframework.messaging.protobuf.Msg) { + return mergeFrom((org.springframework.messaging.protobuf.Msg)other); } else { super.mergeFrom(other); return this; } } - public Builder mergeFrom(Msg other) { - if (other == Msg.getDefaultInstance()) return this; + public Builder mergeFrom(org.springframework.messaging.protobuf.Msg other) { + if (other == org.springframework.messaging.protobuf.Msg.getDefaultInstance()) return this; if (other.hasFoo()) { - bitField0_ |= 0x00000001; foo_ = other.foo_; + bitField0_ |= 0x00000001; onChanged(); } if (other.hasBlah()) { mergeBlah(other.getBlah()); } this.mergeUnknownFields(other.getUnknownFields()); + onChanged(); return this; } + @java.lang.Override public final boolean isInitialized() { return true; } + @java.lang.Override public Builder mergeFrom( com.google.protobuf.CodedInputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - Msg parsedMessage = null; + if (extensionRegistry == null) { + throw new java.lang.NullPointerException(); + } try { - parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); + boolean done = false; + while (!done) { + int tag = input.readTag(); + switch (tag) { + case 0: + done = true; + break; + case 10: { + foo_ = input.readBytes(); + bitField0_ |= 0x00000001; + break; + } // case 10 + case 18: { + input.readMessage( + getBlahFieldBuilder().getBuilder(), + extensionRegistry); + bitField0_ |= 0x00000002; + break; + } // case 18 + default: { + if (!super.parseUnknownField(input, extensionRegistry, tag)) { + done = true; // was an endgroup tag + } + break; + } // default: + } // switch (tag) + } // while (!done) } catch (com.google.protobuf.InvalidProtocolBufferException e) { - parsedMessage = (Msg) e.getUnfinishedMessage(); - throw e; + throw e.unwrapIOException(); } finally { - if (parsedMessage != null) { - mergeFrom(parsedMessage); - } - } + onChanged(); + } // finally return this; } private int bitField0_; - // optional string foo = 1; private java.lang.Object foo_ = ""; /** * optional string foo = 1; + * @return Whether the foo field is set. */ public boolean hasFoo() { - return ((bitField0_ & 0x00000001) == 0x00000001); + return ((bitField0_ & 0x00000001) != 0); } /** * optional string foo = 1; + * @return The foo. */ public java.lang.String getFoo() { java.lang.Object ref = foo_; if (!(ref instanceof java.lang.String)) { - java.lang.String s = ((com.google.protobuf.ByteString) ref) - .toStringUtf8(); - foo_ = s; + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + if (bs.isValidUtf8()) { + foo_ = s; + } return s; } else { return (java.lang.String) ref; @@ -474,12 +492,13 @@ public java.lang.String getFoo() { } /** * optional string foo = 1; + * @return The bytes for foo. */ public com.google.protobuf.ByteString getFooBytes() { java.lang.Object ref = foo_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); foo_ = b; @@ -490,57 +509,58 @@ public java.lang.String getFoo() { } /** * optional string foo = 1; + * @param value The foo to set. + * @return This builder for chaining. */ public Builder setFoo( java.lang.String value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000001; + if (value == null) { throw new NullPointerException(); } foo_ = value; + bitField0_ |= 0x00000001; onChanged(); return this; } /** * optional string foo = 1; + * @return This builder for chaining. */ public Builder clearFoo() { - bitField0_ = (bitField0_ & ~0x00000001); foo_ = getDefaultInstance().getFoo(); + bitField0_ = (bitField0_ & ~0x00000001); onChanged(); return this; } /** * optional string foo = 1; + * @param value The bytes for foo to set. + * @return This builder for chaining. */ public Builder setFooBytes( com.google.protobuf.ByteString value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000001; + if (value == null) { throw new NullPointerException(); } foo_ = value; + bitField0_ |= 0x00000001; onChanged(); return this; } - // optional .SecondMsg blah = 2; - private SecondMsg blah_ = SecondMsg.getDefaultInstance(); + private org.springframework.messaging.protobuf.SecondMsg blah_; private com.google.protobuf.SingleFieldBuilder< - SecondMsg, SecondMsg.Builder, - SecondMsgOrBuilder> blahBuilder_; + org.springframework.messaging.protobuf.SecondMsg, org.springframework.messaging.protobuf.SecondMsg.Builder, org.springframework.messaging.protobuf.SecondMsgOrBuilder> blahBuilder_; /** * optional .SecondMsg blah = 2; + * @return Whether the blah field is set. */ public boolean hasBlah() { - return ((bitField0_ & 0x00000002) == 0x00000002); + return ((bitField0_ & 0x00000002) != 0); } /** * optional .SecondMsg blah = 2; + * @return The blah. */ - public SecondMsg getBlah() { + public org.springframework.messaging.protobuf.SecondMsg getBlah() { if (blahBuilder_ == null) { - return blah_; + return blah_ == null ? org.springframework.messaging.protobuf.SecondMsg.getDefaultInstance() : blah_; } else { return blahBuilder_.getMessage(); } @@ -548,69 +568,71 @@ public SecondMsg getBlah() { /** * optional .SecondMsg blah = 2; */ - public Builder setBlah(SecondMsg value) { + public Builder setBlah(org.springframework.messaging.protobuf.SecondMsg value) { if (blahBuilder_ == null) { if (value == null) { throw new NullPointerException(); } blah_ = value; - onChanged(); } else { blahBuilder_.setMessage(value); } bitField0_ |= 0x00000002; + onChanged(); return this; } /** * optional .SecondMsg blah = 2; */ public Builder setBlah( - SecondMsg.Builder builderForValue) { + org.springframework.messaging.protobuf.SecondMsg.Builder builderForValue) { if (blahBuilder_ == null) { blah_ = builderForValue.build(); - onChanged(); } else { blahBuilder_.setMessage(builderForValue.build()); } bitField0_ |= 0x00000002; + onChanged(); return this; } /** * optional .SecondMsg blah = 2; */ - public Builder mergeBlah(SecondMsg value) { + public Builder mergeBlah(org.springframework.messaging.protobuf.SecondMsg value) { if (blahBuilder_ == null) { - if (((bitField0_ & 0x00000002) == 0x00000002) && - blah_ != SecondMsg.getDefaultInstance()) { - blah_ = - SecondMsg.newBuilder(blah_).mergeFrom(value).buildPartial(); + if (((bitField0_ & 0x00000002) != 0) && + blah_ != null && + blah_ != org.springframework.messaging.protobuf.SecondMsg.getDefaultInstance()) { + getBlahBuilder().mergeFrom(value); } else { blah_ = value; } - onChanged(); } else { blahBuilder_.mergeFrom(value); } - bitField0_ |= 0x00000002; + if (blah_ != null) { + bitField0_ |= 0x00000002; + onChanged(); + } return this; } /** * optional .SecondMsg blah = 2; */ public Builder clearBlah() { - if (blahBuilder_ == null) { - blah_ = SecondMsg.getDefaultInstance(); - onChanged(); - } else { - blahBuilder_.clear(); - } bitField0_ = (bitField0_ & ~0x00000002); + blah_ = null; + if (blahBuilder_ != null) { + blahBuilder_.dispose(); + blahBuilder_ = null; + } + onChanged(); return this; } /** * optional .SecondMsg blah = 2; */ - public SecondMsg.Builder getBlahBuilder() { + public org.springframework.messaging.protobuf.SecondMsg.Builder getBlahBuilder() { bitField0_ |= 0x00000002; onChanged(); return getBlahFieldBuilder().getBuilder(); @@ -618,25 +640,26 @@ public SecondMsg.Builder getBlahBuilder() { /** * optional .SecondMsg blah = 2; */ - public SecondMsgOrBuilder getBlahOrBuilder() { + public org.springframework.messaging.protobuf.SecondMsgOrBuilder getBlahOrBuilder() { if (blahBuilder_ != null) { return blahBuilder_.getMessageOrBuilder(); } else { - return blah_; + return blah_ == null ? + org.springframework.messaging.protobuf.SecondMsg.getDefaultInstance() : blah_; } } /** * optional .SecondMsg blah = 2; */ private com.google.protobuf.SingleFieldBuilder< - SecondMsg, SecondMsg.Builder, - SecondMsgOrBuilder> + org.springframework.messaging.protobuf.SecondMsg, org.springframework.messaging.protobuf.SecondMsg.Builder, org.springframework.messaging.protobuf.SecondMsgOrBuilder> getBlahFieldBuilder() { if (blahBuilder_ == null) { - blahBuilder_ = new com.google.protobuf.SingleFieldBuilder<>( - blah_, - getParentForChildren(), - isClean()); + blahBuilder_ = new com.google.protobuf.SingleFieldBuilder< + org.springframework.messaging.protobuf.SecondMsg, org.springframework.messaging.protobuf.SecondMsg.Builder, org.springframework.messaging.protobuf.SecondMsgOrBuilder>( + getBlah(), + getParentForChildren(), + isClean()); blah_ = null; } return blahBuilder_; @@ -645,11 +668,51 @@ public SecondMsgOrBuilder getBlahOrBuilder() { // @@protoc_insertion_point(builder_scope:Msg) } + // @@protoc_insertion_point(class_scope:Msg) + private static final org.springframework.messaging.protobuf.Msg DEFAULT_INSTANCE; static { - defaultInstance = new Msg(true); - defaultInstance.initFields(); + DEFAULT_INSTANCE = new org.springframework.messaging.protobuf.Msg(); + } + + public static org.springframework.messaging.protobuf.Msg getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final com.google.protobuf.Parser + PARSER = new com.google.protobuf.AbstractParser() { + @java.lang.Override + public Msg parsePartialFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + Builder builder = newBuilder(); + try { + builder.mergeFrom(input, extensionRegistry); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.setUnfinishedMessage(builder.buildPartial()); + } catch (com.google.protobuf.UninitializedMessageException e) { + throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial()); + } catch (java.io.IOException e) { + throw new com.google.protobuf.InvalidProtocolBufferException(e) + .setUnfinishedMessage(builder.buildPartial()); + } + return builder.buildPartial(); + } + }; + + public static com.google.protobuf.Parser parser() { + return PARSER; + } + + @java.lang.Override + public com.google.protobuf.Parser getParserForType() { + return PARSER; + } + + @java.lang.Override + public org.springframework.messaging.protobuf.Msg getDefaultInstanceForType() { + return DEFAULT_INSTANCE; } - // @@protoc_insertion_point(class_scope:Msg) } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/protobuf/MsgOrBuilder.java b/spring-messaging/src/test/java/org/springframework/messaging/protobuf/MsgOrBuilder.java index ca9f94be7450..278e0787a308 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/protobuf/MsgOrBuilder.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/protobuf/MsgOrBuilder.java @@ -1,37 +1,43 @@ // Generated by the protocol buffer compiler. DO NOT EDIT! +// NO CHECKED-IN PROTOBUF GENCODE // source: sample.proto +// Protobuf Java Version: 4.27.0 package org.springframework.messaging.protobuf; -public interface MsgOrBuilder - extends com.google.protobuf.MessageOrBuilder { +public interface MsgOrBuilder extends + // @@protoc_insertion_point(interface_extends:Msg) + com.google.protobuf.MessageOrBuilder { - // optional string foo = 1; /** * optional string foo = 1; + * @return Whether the foo field is set. */ boolean hasFoo(); /** * optional string foo = 1; + * @return The foo. */ java.lang.String getFoo(); /** * optional string foo = 1; + * @return The bytes for foo. */ com.google.protobuf.ByteString getFooBytes(); - // optional .SecondMsg blah = 2; /** * optional .SecondMsg blah = 2; + * @return Whether the blah field is set. */ boolean hasBlah(); /** * optional .SecondMsg blah = 2; + * @return The blah. */ - SecondMsg getBlah(); + org.springframework.messaging.protobuf.SecondMsg getBlah(); /** * optional .SecondMsg blah = 2; */ - SecondMsgOrBuilder getBlahOrBuilder(); + org.springframework.messaging.protobuf.SecondMsgOrBuilder getBlahOrBuilder(); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/protobuf/OuterSample.java b/spring-messaging/src/test/java/org/springframework/messaging/protobuf/OuterSample.java index 463efe67acf6..cbfdd4ac6b61 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/protobuf/OuterSample.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/protobuf/OuterSample.java @@ -1,38 +1,38 @@ -/* - * Copyright 2002-2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.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. - */ - // Generated by the protocol buffer compiler. DO NOT EDIT! +// NO CHECKED-IN PROTOBUF GENCODE // source: sample.proto +// Protobuf Java Version: 4.27.0 package org.springframework.messaging.protobuf; -@SuppressWarnings("deprecation") -public class OuterSample { +public final class OuterSample { private OuterSample() {} + static { + com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion( + com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, + /* major= */ 4, + /* minor= */ 27, + /* patch= */ 0, + /* suffix= */ "", + OuterSample.class.getName()); + } + public static void registerAllExtensions( + com.google.protobuf.ExtensionRegistryLite registry) { + } + public static void registerAllExtensions( com.google.protobuf.ExtensionRegistry registry) { + registerAllExtensions( + (com.google.protobuf.ExtensionRegistryLite) registry); } - static com.google.protobuf.Descriptors.Descriptor + static final com.google.protobuf.Descriptors.Descriptor internal_static_Msg_descriptor; - static + static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_Msg_fieldAccessorTable; - static com.google.protobuf.Descriptors.Descriptor + static final com.google.protobuf.Descriptors.Descriptor internal_static_SecondMsg_descriptor; - static + static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_SecondMsg_fieldAccessorTable; @@ -40,39 +40,32 @@ public static void registerAllExtensions( getDescriptor() { return descriptor; } - private static com.google.protobuf.Descriptors.FileDescriptor + private static com.google.protobuf.Descriptors.FileDescriptor descriptor; static { java.lang.String[] descriptorData = { "\n\014sample.proto\",\n\003Msg\022\013\n\003foo\030\001 \001(\t\022\030\n\004bl" + "ah\030\002 \001(\0132\n.SecondMsg\"\031\n\tSecondMsg\022\014\n\004bla" + - "h\030\001 \001(\005B-\n\034org.springframework.protobufB" + - "\013OuterSampleP\001" + "h\030\001 \001(\005B7\n&org.springframework.messaging" + + ".protobufB\013OuterSampleP\001" }; - com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner assigner = - new com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner() { - public com.google.protobuf.ExtensionRegistry assignDescriptors( - com.google.protobuf.Descriptors.FileDescriptor root) { - descriptor = root; - internal_static_Msg_descriptor = - getDescriptor().getMessageTypes().get(0); - internal_static_Msg_fieldAccessorTable = new - com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_Msg_descriptor, - new java.lang.String[] { "Foo", "Blah", }); - internal_static_SecondMsg_descriptor = - getDescriptor().getMessageTypes().get(1); - internal_static_SecondMsg_fieldAccessorTable = new - com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_SecondMsg_descriptor, - new java.lang.String[] { "Blah", }); - return null; - } - }; - com.google.protobuf.Descriptors.FileDescriptor + descriptor = com.google.protobuf.Descriptors.FileDescriptor .internalBuildGeneratedFileFrom(descriptorData, new com.google.protobuf.Descriptors.FileDescriptor[] { - }, assigner); + }); + internal_static_Msg_descriptor = + getDescriptor().getMessageTypes().get(0); + internal_static_Msg_fieldAccessorTable = new + com.google.protobuf.GeneratedMessage.FieldAccessorTable( + internal_static_Msg_descriptor, + new java.lang.String[] { "Foo", "Blah", }); + internal_static_SecondMsg_descriptor = + getDescriptor().getMessageTypes().get(1); + internal_static_SecondMsg_fieldAccessorTable = new + com.google.protobuf.GeneratedMessage.FieldAccessorTable( + internal_static_SecondMsg_descriptor, + new java.lang.String[] { "Blah", }); + descriptor.resolveAllFeaturesImmutable(); } // @@protoc_insertion_point(outer_class_scope) diff --git a/spring-messaging/src/test/java/org/springframework/messaging/protobuf/SecondMsg.java b/spring-messaging/src/test/java/org/springframework/messaging/protobuf/SecondMsg.java index d2630a320d39..923927096caa 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/protobuf/SecondMsg.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/protobuf/SecondMsg.java @@ -1,224 +1,222 @@ // Generated by the protocol buffer compiler. DO NOT EDIT! +// NO CHECKED-IN PROTOBUF GENCODE // source: sample.proto +// Protobuf Java Version: 4.27.0 package org.springframework.messaging.protobuf; /** * Protobuf type {@code SecondMsg} */ -@SuppressWarnings("serial") -public final class SecondMsg extends - com.google.protobuf.GeneratedMessage - implements SecondMsgOrBuilder { +public final class SecondMsg extends + com.google.protobuf.GeneratedMessage implements + // @@protoc_insertion_point(message_implements:SecondMsg) + SecondMsgOrBuilder { +private static final long serialVersionUID = 0L; + static { + com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion( + com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, + /* major= */ 4, + /* minor= */ 27, + /* patch= */ 0, + /* suffix= */ "", + SecondMsg.class.getName()); + } // Use SecondMsg.newBuilder() to construct. private SecondMsg(com.google.protobuf.GeneratedMessage.Builder builder) { super(builder); - this.unknownFields = builder.getUnknownFields(); } - private SecondMsg(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } - - private static final SecondMsg defaultInstance; - public static SecondMsg getDefaultInstance() { - return defaultInstance; + private SecondMsg() { } - public SecondMsg getDefaultInstanceForType() { - return defaultInstance; - } - - private final com.google.protobuf.UnknownFieldSet unknownFields; - @java.lang.Override - public final com.google.protobuf.UnknownFieldSet - getUnknownFields() { - return this.unknownFields; - } - private SecondMsg( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - initFields(); - @SuppressWarnings("unused") - int mutable_bitField0_ = 0; - com.google.protobuf.UnknownFieldSet.Builder unknownFields = - com.google.protobuf.UnknownFieldSet.newBuilder(); - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - default: { - if (!parseUnknownField(input, unknownFields, - extensionRegistry, tag)) { - done = true; - } - break; - } - case 8: { - bitField0_ |= 0x00000001; - blah_ = input.readInt32(); - break; - } - } - } - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(this); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException( - e.getMessage()).setUnfinishedMessage(this); - } finally { - this.unknownFields = unknownFields.build(); - makeExtensionsImmutable(); - } - } public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return OuterSample.internal_static_SecondMsg_descriptor; + return org.springframework.messaging.protobuf.OuterSample.internal_static_SecondMsg_descriptor; } + @java.lang.Override protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return OuterSample.internal_static_SecondMsg_fieldAccessorTable + return org.springframework.messaging.protobuf.OuterSample.internal_static_SecondMsg_fieldAccessorTable .ensureFieldAccessorsInitialized( - SecondMsg.class, SecondMsg.Builder.class); - } - - public static com.google.protobuf.Parser PARSER = - new com.google.protobuf.AbstractParser() { - public SecondMsg parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return new SecondMsg(input, extensionRegistry); - } - }; - - @java.lang.Override - public com.google.protobuf.Parser getParserForType() { - return PARSER; + org.springframework.messaging.protobuf.SecondMsg.class, org.springframework.messaging.protobuf.SecondMsg.Builder.class); } private int bitField0_; - // optional int32 blah = 1; public static final int BLAH_FIELD_NUMBER = 1; - private int blah_; + private int blah_ = 0; /** * optional int32 blah = 1; + * @return Whether the blah field is set. */ + @java.lang.Override public boolean hasBlah() { - return ((bitField0_ & 0x00000001) == 0x00000001); + return ((bitField0_ & 0x00000001) != 0); } /** * optional int32 blah = 1; + * @return The blah. */ + @java.lang.Override public int getBlah() { return blah_; } - private void initFields() { - blah_ = 0; - } private byte memoizedIsInitialized = -1; + @java.lang.Override public final boolean isInitialized() { byte isInitialized = memoizedIsInitialized; - if (isInitialized != -1) return isInitialized == 1; + if (isInitialized == 1) return true; + if (isInitialized == 0) return false; memoizedIsInitialized = 1; return true; } + @java.lang.Override public void writeTo(com.google.protobuf.CodedOutputStream output) throws java.io.IOException { - getSerializedSize(); - if (((bitField0_ & 0x00000001) == 0x00000001)) { + if (((bitField0_ & 0x00000001) != 0)) { output.writeInt32(1, blah_); } getUnknownFields().writeTo(output); } - private int memoizedSerializedSize = -1; + @java.lang.Override public int getSerializedSize() { - int size = memoizedSerializedSize; + int size = memoizedSize; if (size != -1) return size; size = 0; - if (((bitField0_ & 0x00000001) == 0x00000001)) { + if (((bitField0_ & 0x00000001) != 0)) { size += com.google.protobuf.CodedOutputStream .computeInt32Size(1, blah_); } size += getUnknownFields().getSerializedSize(); - memoizedSerializedSize = size; + memoizedSize = size; return size; } - private static final long serialVersionUID = 0L; @java.lang.Override - protected java.lang.Object writeReplace() - throws java.io.ObjectStreamException { - return super.writeReplace(); + public boolean equals(final java.lang.Object obj) { + if (obj == this) { + return true; + } + if (!(obj instanceof org.springframework.messaging.protobuf.SecondMsg)) { + return super.equals(obj); + } + org.springframework.messaging.protobuf.SecondMsg other = (org.springframework.messaging.protobuf.SecondMsg) obj; + + if (hasBlah() != other.hasBlah()) return false; + if (hasBlah()) { + if (getBlah() + != other.getBlah()) return false; + } + if (!getUnknownFields().equals(other.getUnknownFields())) return false; + return true; + } + + @java.lang.Override + public int hashCode() { + if (memoizedHashCode != 0) { + return memoizedHashCode; + } + int hash = 41; + hash = (19 * hash) + getDescriptor().hashCode(); + if (hasBlah()) { + hash = (37 * hash) + BLAH_FIELD_NUMBER; + hash = (53 * hash) + getBlah(); + } + hash = (29 * hash) + getUnknownFields().hashCode(); + memoizedHashCode = hash; + return hash; } - public static SecondMsg parseFrom( + public static org.springframework.messaging.protobuf.SecondMsg parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static org.springframework.messaging.protobuf.SecondMsg parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static org.springframework.messaging.protobuf.SecondMsg parseFrom( com.google.protobuf.ByteString data) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data); } - public static SecondMsg parseFrom( + public static org.springframework.messaging.protobuf.SecondMsg parseFrom( com.google.protobuf.ByteString data, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data, extensionRegistry); } - public static SecondMsg parseFrom(byte[] data) + public static org.springframework.messaging.protobuf.SecondMsg parseFrom(byte[] data) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data); } - public static SecondMsg parseFrom( + public static org.springframework.messaging.protobuf.SecondMsg parseFrom( byte[] data, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data, extensionRegistry); } - public static SecondMsg parseFrom(java.io.InputStream input) + public static org.springframework.messaging.protobuf.SecondMsg parseFrom(java.io.InputStream input) throws java.io.IOException { - return PARSER.parseFrom(input); + return com.google.protobuf.GeneratedMessage + .parseWithIOException(PARSER, input); } - public static SecondMsg parseFrom( + public static org.springframework.messaging.protobuf.SecondMsg parseFrom( java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); + return com.google.protobuf.GeneratedMessage + .parseWithIOException(PARSER, input, extensionRegistry); } - public static SecondMsg parseDelimitedFrom(java.io.InputStream input) + + public static org.springframework.messaging.protobuf.SecondMsg parseDelimitedFrom(java.io.InputStream input) throws java.io.IOException { - return PARSER.parseDelimitedFrom(input); + return com.google.protobuf.GeneratedMessage + .parseDelimitedWithIOException(PARSER, input); } - public static SecondMsg parseDelimitedFrom( + + public static org.springframework.messaging.protobuf.SecondMsg parseDelimitedFrom( java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - return PARSER.parseDelimitedFrom(input, extensionRegistry); + return com.google.protobuf.GeneratedMessage + .parseDelimitedWithIOException(PARSER, input, extensionRegistry); } - public static SecondMsg parseFrom( + public static org.springframework.messaging.protobuf.SecondMsg parseFrom( com.google.protobuf.CodedInputStream input) throws java.io.IOException { - return PARSER.parseFrom(input); + return com.google.protobuf.GeneratedMessage + .parseWithIOException(PARSER, input); } - public static SecondMsg parseFrom( + public static org.springframework.messaging.protobuf.SecondMsg parseFrom( com.google.protobuf.CodedInputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); + return com.google.protobuf.GeneratedMessage + .parseWithIOException(PARSER, input, extensionRegistry); } - public static Builder newBuilder() { return Builder.create(); } + @java.lang.Override public Builder newBuilderForType() { return newBuilder(); } - public static Builder newBuilder(SecondMsg prototype) { - return newBuilder().mergeFrom(prototype); + public static Builder newBuilder() { + return DEFAULT_INSTANCE.toBuilder(); + } + public static Builder newBuilder(org.springframework.messaging.protobuf.SecondMsg prototype) { + return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); + } + @java.lang.Override + public Builder toBuilder() { + return this == DEFAULT_INSTANCE + ? new Builder() : new Builder().mergeFrom(this); } - public Builder toBuilder() { return newBuilder(this); } @java.lang.Override protected Builder newBuilderForType( @@ -230,145 +228,173 @@ protected Builder newBuilderForType( * Protobuf type {@code SecondMsg} */ public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder - implements SecondMsgOrBuilder { + com.google.protobuf.GeneratedMessage.Builder implements + // @@protoc_insertion_point(builder_implements:SecondMsg) + org.springframework.messaging.protobuf.SecondMsgOrBuilder { public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return OuterSample.internal_static_SecondMsg_descriptor; + return org.springframework.messaging.protobuf.OuterSample.internal_static_SecondMsg_descriptor; } + @java.lang.Override protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return OuterSample.internal_static_SecondMsg_fieldAccessorTable + return org.springframework.messaging.protobuf.OuterSample.internal_static_SecondMsg_fieldAccessorTable .ensureFieldAccessorsInitialized( - SecondMsg.class, SecondMsg.Builder.class); + org.springframework.messaging.protobuf.SecondMsg.class, org.springframework.messaging.protobuf.SecondMsg.Builder.class); } // Construct using org.springframework.messaging.protobuf.SecondMsg.newBuilder() private Builder() { - maybeForceBuilderInitialization(); + } private Builder( com.google.protobuf.GeneratedMessage.BuilderParent parent) { super(parent); - maybeForceBuilderInitialization(); - } - private void maybeForceBuilderInitialization() { - if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { - } - } - private static Builder create() { - return new Builder(); - } + } + @java.lang.Override public Builder clear() { super.clear(); + bitField0_ = 0; blah_ = 0; - bitField0_ = (bitField0_ & ~0x00000001); return this; } - public Builder clone() { - return create().mergeFrom(buildPartial()); - } - + @java.lang.Override public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() { - return OuterSample.internal_static_SecondMsg_descriptor; + return org.springframework.messaging.protobuf.OuterSample.internal_static_SecondMsg_descriptor; } - public SecondMsg getDefaultInstanceForType() { - return SecondMsg.getDefaultInstance(); + @java.lang.Override + public org.springframework.messaging.protobuf.SecondMsg getDefaultInstanceForType() { + return org.springframework.messaging.protobuf.SecondMsg.getDefaultInstance(); } - public SecondMsg build() { - SecondMsg result = buildPartial(); + @java.lang.Override + public org.springframework.messaging.protobuf.SecondMsg build() { + org.springframework.messaging.protobuf.SecondMsg result = buildPartial(); if (!result.isInitialized()) { throw newUninitializedMessageException(result); } return result; } - public SecondMsg buildPartial() { - SecondMsg result = new SecondMsg(this); + @java.lang.Override + public org.springframework.messaging.protobuf.SecondMsg buildPartial() { + org.springframework.messaging.protobuf.SecondMsg result = new org.springframework.messaging.protobuf.SecondMsg(this); + if (bitField0_ != 0) { buildPartial0(result); } + onBuilt(); + return result; + } + + private void buildPartial0(org.springframework.messaging.protobuf.SecondMsg result) { int from_bitField0_ = bitField0_; int to_bitField0_ = 0; - if (((from_bitField0_ & 0x00000001) == 0x00000001)) { + if (((from_bitField0_ & 0x00000001) != 0)) { + result.blah_ = blah_; to_bitField0_ |= 0x00000001; } - result.blah_ = blah_; - result.bitField0_ = to_bitField0_; - onBuilt(); - return result; + result.bitField0_ |= to_bitField0_; } + @java.lang.Override public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof SecondMsg) { - return mergeFrom((SecondMsg)other); + if (other instanceof org.springframework.messaging.protobuf.SecondMsg) { + return mergeFrom((org.springframework.messaging.protobuf.SecondMsg)other); } else { super.mergeFrom(other); return this; } } - public Builder mergeFrom(SecondMsg other) { - if (other == SecondMsg.getDefaultInstance()) return this; + public Builder mergeFrom(org.springframework.messaging.protobuf.SecondMsg other) { + if (other == org.springframework.messaging.protobuf.SecondMsg.getDefaultInstance()) return this; if (other.hasBlah()) { setBlah(other.getBlah()); } this.mergeUnknownFields(other.getUnknownFields()); + onChanged(); return this; } + @java.lang.Override public final boolean isInitialized() { return true; } + @java.lang.Override public Builder mergeFrom( com.google.protobuf.CodedInputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - SecondMsg parsedMessage = null; + if (extensionRegistry == null) { + throw new java.lang.NullPointerException(); + } try { - parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); + boolean done = false; + while (!done) { + int tag = input.readTag(); + switch (tag) { + case 0: + done = true; + break; + case 8: { + blah_ = input.readInt32(); + bitField0_ |= 0x00000001; + break; + } // case 8 + default: { + if (!super.parseUnknownField(input, extensionRegistry, tag)) { + done = true; // was an endgroup tag + } + break; + } // default: + } // switch (tag) + } // while (!done) } catch (com.google.protobuf.InvalidProtocolBufferException e) { - parsedMessage = (SecondMsg) e.getUnfinishedMessage(); - throw e; + throw e.unwrapIOException(); } finally { - if (parsedMessage != null) { - mergeFrom(parsedMessage); - } - } + onChanged(); + } // finally return this; } private int bitField0_; - // optional int32 blah = 1; private int blah_ ; /** * optional int32 blah = 1; + * @return Whether the blah field is set. */ + @java.lang.Override public boolean hasBlah() { - return ((bitField0_ & 0x00000001) == 0x00000001); + return ((bitField0_ & 0x00000001) != 0); } /** * optional int32 blah = 1; + * @return The blah. */ + @java.lang.Override public int getBlah() { return blah_; } /** * optional int32 blah = 1; + * @param value The blah to set. + * @return This builder for chaining. */ public Builder setBlah(int value) { - bitField0_ |= 0x00000001; + blah_ = value; + bitField0_ |= 0x00000001; onChanged(); return this; } /** * optional int32 blah = 1; + * @return This builder for chaining. */ public Builder clearBlah() { bitField0_ = (bitField0_ & ~0x00000001); @@ -380,11 +406,51 @@ public Builder clearBlah() { // @@protoc_insertion_point(builder_scope:SecondMsg) } + // @@protoc_insertion_point(class_scope:SecondMsg) + private static final org.springframework.messaging.protobuf.SecondMsg DEFAULT_INSTANCE; static { - defaultInstance = new SecondMsg(true); - defaultInstance.initFields(); + DEFAULT_INSTANCE = new org.springframework.messaging.protobuf.SecondMsg(); + } + + public static org.springframework.messaging.protobuf.SecondMsg getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final com.google.protobuf.Parser + PARSER = new com.google.protobuf.AbstractParser() { + @java.lang.Override + public SecondMsg parsePartialFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + Builder builder = newBuilder(); + try { + builder.mergeFrom(input, extensionRegistry); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.setUnfinishedMessage(builder.buildPartial()); + } catch (com.google.protobuf.UninitializedMessageException e) { + throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial()); + } catch (java.io.IOException e) { + throw new com.google.protobuf.InvalidProtocolBufferException(e) + .setUnfinishedMessage(builder.buildPartial()); + } + return builder.buildPartial(); + } + }; + + public static com.google.protobuf.Parser parser() { + return PARSER; + } + + @java.lang.Override + public com.google.protobuf.Parser getParserForType() { + return PARSER; + } + + @java.lang.Override + public org.springframework.messaging.protobuf.SecondMsg getDefaultInstanceForType() { + return DEFAULT_INSTANCE; } - // @@protoc_insertion_point(class_scope:SecondMsg) } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/protobuf/SecondMsgOrBuilder.java b/spring-messaging/src/test/java/org/springframework/messaging/protobuf/SecondMsgOrBuilder.java index fc4ff1576cd6..9a45eae14834 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/protobuf/SecondMsgOrBuilder.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/protobuf/SecondMsgOrBuilder.java @@ -1,18 +1,22 @@ // Generated by the protocol buffer compiler. DO NOT EDIT! +// NO CHECKED-IN PROTOBUF GENCODE // source: sample.proto +// Protobuf Java Version: 4.27.0 package org.springframework.messaging.protobuf; -public interface SecondMsgOrBuilder - extends com.google.protobuf.MessageOrBuilder { +public interface SecondMsgOrBuilder extends + // @@protoc_insertion_point(interface_extends:SecondMsg) + com.google.protobuf.MessageOrBuilder { - // optional int32 blah = 1; /** * optional int32 blah = 1; + * @return Whether the blah field is set. */ boolean hasBlah(); /** * optional int32 blah = 1; + * @return The blah. */ int getBlah(); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/service/PayloadArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/service/PayloadArgumentResolverTests.java index d3ba5ba03113..f1f5ac9527d1 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/service/PayloadArgumentResolverTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/service/PayloadArgumentResolverTests.java @@ -25,6 +25,7 @@ import org.springframework.core.MethodParameter; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.lang.Nullable; import org.springframework.messaging.handler.annotation.Payload; import static org.assertj.core.api.Assertions.assertThat; @@ -34,7 +35,9 @@ * Tests for {@link PayloadArgumentResolver}. * * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma */ +@SuppressWarnings("DataFlowIssue") class PayloadArgumentResolverTests extends RSocketServiceArgumentResolverTestSupport { @Override @@ -47,9 +50,7 @@ void stringPayload() { String payload = "payloadValue"; boolean resolved = execute(payload, initMethodParameter(Service.class, "execute", 0)); - assertThat(resolved).isTrue(); - assertThat(getRequestValues().getPayloadValue()).isEqualTo(payload); - assertThat(getRequestValues().getPayload()).isNull(); + assertPayload(resolved, payload); } @Test @@ -57,10 +58,7 @@ void monoPayload() { Mono payloadMono = Mono.just("payloadValue"); boolean resolved = execute(payloadMono, initMethodParameter(Service.class, "executeMono", 0)); - assertThat(resolved).isTrue(); - assertThat(getRequestValues().getPayloadValue()).isNull(); - assertThat(getRequestValues().getPayload()).isSameAs(payloadMono); - assertThat(getRequestValues().getPayloadElementType()).isEqualTo(new ParameterizedTypeReference() {}); + assertPayloadMono(resolved, payloadMono); } @Test @@ -92,7 +90,7 @@ void completable() { } @Test - void notRequestBody() { + void notPayload() { MethodParameter parameter = initMethodParameter(Service.class, "executeNotAnnotated", 0); boolean resolved = execute("value", parameter); @@ -100,23 +98,69 @@ void notRequestBody() { } @Test - void ignoreNull() { - boolean resolved = execute(null, initMethodParameter(Service.class, "execute", 0)); + void nullPayload() { + assertThatIllegalArgumentException() + .isThrownBy(() -> execute(null, initMethodParameter(Service.class, "execute", 0))) + .withMessage("Missing payload"); + + assertThatIllegalArgumentException() + .isThrownBy(() -> execute(null, initMethodParameter(Service.class, "executeMono", 0))) + .withMessage("Missing payload"); + } + + @Test + void nullPayloadWithNullable() { + boolean resolved = execute(null, initMethodParameter(Service.class, "executeNullable", 0)); + assertNullValues(resolved); + + boolean resolvedMono = execute(null, initMethodParameter(Service.class, "executeNullableMono", 0)); + assertNullValues(resolvedMono); + } + + @Test + void nullPayloadWithNotRequired() { + boolean resolved = execute(null, initMethodParameter(Service.class, "executeNotRequired", 0)); + assertNullValues(resolved); + boolean resolvedMono = execute(null, initMethodParameter(Service.class, "executeNotRequiredMono", 0)); + assertNullValues(resolvedMono); + } + + private void assertPayload(boolean resolved, String payload) { + assertThat(resolved).isTrue(); + assertThat(getRequestValues().getPayloadValue()).isEqualTo(payload); + assertThat(getRequestValues().getPayload()).isNull(); + } + + private void assertPayloadMono(boolean resolved, Mono payloadMono) { + assertThat(resolved).isTrue(); + assertThat(getRequestValues().getPayloadValue()).isNull(); + assertThat(getRequestValues().getPayload()).isSameAs(payloadMono); + assertThat(getRequestValues().getPayloadElementType()).isEqualTo(new ParameterizedTypeReference() { }); + } + + private void assertNullValues(boolean resolved) { assertThat(resolved).isTrue(); assertThat(getRequestValues().getPayloadValue()).isNull(); assertThat(getRequestValues().getPayload()).isNull(); assertThat(getRequestValues().getPayloadElementType()).isNull(); } - - @SuppressWarnings("unused") + @SuppressWarnings({"unused"}) private interface Service { void execute(@Payload String body); + void executeNotRequired(@Payload(required = false) String body); + + void executeNullable(@Nullable @Payload String body); + void executeMono(@Payload Mono body); + void executeNullableMono(@Nullable @Payload Mono body); + + void executeNotRequiredMono(@Payload(required = false) Mono body); + void executeSingle(@Payload Single body); void executeMonoVoid(@Payload Mono body); diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/service/RSocketServiceIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/service/RSocketServiceIntegrationTests.java index 595f1ebd0bac..676ea2d345dd 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/service/RSocketServiceIntegrationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/service/RSocketServiceIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -127,10 +127,12 @@ interface Service { @Controller static class ServerController implements Service { + @Override public Mono echoAsync(String payload) { return Mono.delay(Duration.ofMillis(10)).map(aLong -> payload + " async"); } + @Override public Flux echoStream(String payload) { return Flux.interval(Duration.ofMillis(10)).map(aLong -> payload + " " + aLong); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/SimpAttributesContextHolderTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/SimpAttributesContextHolderTests.java index bf4bc105ef86..1132f5458724 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/SimpAttributesContextHolderTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/SimpAttributesContextHolderTests.java @@ -103,7 +103,7 @@ void setAttributesFromMessage() { @Test void setAttributesFromMessageWithMissingSessionId() { assertThatIllegalStateException().isThrownBy(() -> - SimpAttributesContextHolder.setAttributesFromMessage(new GenericMessage(""))) + SimpAttributesContextHolder.setAttributesFromMessage(new GenericMessage<>(""))) .withMessageStartingWith("No session id in"); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/SimpMessagingTemplateTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/SimpMessagingTemplateTests.java index 43ece3dce951..47fd91b65b54 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/SimpMessagingTemplateTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/SimpMessagingTemplateTests.java @@ -111,7 +111,7 @@ void convertAndSendWithCustomHeader() { void convertAndSendWithCustomHeaderNonNative() { Map headers = new HashMap<>(); headers.put("key", "value"); - headers.put(NativeMessageHeaderAccessor.NATIVE_HEADERS, new LinkedMultiValueMap()); + headers.put(NativeMessageHeaderAccessor.NATIVE_HEADERS, new LinkedMultiValueMap<>()); this.messagingTemplate.convertAndSend("/foo", "data", headers); List> messages = this.messageChannel.getMessages(); diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SimpAnnotationMethodMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SimpAnnotationMethodMessageHandlerTests.java index 896aa347170d..532838457aa5 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SimpAnnotationMethodMessageHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SimpAnnotationMethodMessageHandlerTests.java @@ -546,6 +546,7 @@ private static class InterfaceBasedController implements ControllerInterface { Map arguments = new LinkedHashMap<>(); + @Override @MessageMapping("/binding/id/{id}") public void simpleBinding(Long id) { this.method = "simpleBinding"; @@ -570,7 +571,7 @@ public void handleFoo() { @Controller @MessageMapping("listenable-future") - @SuppressWarnings("deprecation") + @SuppressWarnings({"deprecation", "removal"}) private static class ListenableFutureController { org.springframework.util.concurrent.ListenableFutureTask future; diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/broker/BrokerMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/broker/BrokerMessageHandlerTests.java index b669b68a492e..d9e598b03b39 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/broker/BrokerMessageHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/broker/BrokerMessageHandlerTests.java @@ -69,7 +69,7 @@ void startAndStopShouldNotPublishBrokerAvailabilityEvents() { @Test void handleMessageWhenBrokerNotRunning() { - this.handler.handleMessage(new GenericMessage("payload")); + this.handler.handleMessage(new GenericMessage<>("payload")); assertThat(this.handler.messages).isEqualTo(Collections.emptyList()); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/config/ChannelRegistrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/config/ChannelRegistrationTests.java index dc392e2437e0..3cc45927c9f0 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/config/ChannelRegistrationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/config/ChannelRegistrationTests.java @@ -16,12 +16,12 @@ package org.springframework.messaging.simp.config; +import java.util.concurrent.Executor; import java.util.function.Consumer; import java.util.function.Supplier; import org.junit.jupiter.api.Test; -import org.springframework.core.task.TaskExecutor; import org.springframework.messaging.support.ChannelInterceptor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @@ -38,20 +38,20 @@ */ class ChannelRegistrationTests { - private final Supplier fallback = mock(); + private final Supplier fallback = mock(); - private final Consumer customizer = mock(); + private final Consumer customizer = mock(); @Test void emptyRegistrationUsesFallback() { - TaskExecutor fallbackTaskExecutor = mock(TaskExecutor.class); - given(this.fallback.get()).willReturn(fallbackTaskExecutor); + Executor fallbackExecutor = mock(Executor.class); + given(this.fallback.get()).willReturn(fallbackExecutor); ChannelRegistration registration = new ChannelRegistration(); - assertThat(registration.hasTaskExecutor()).isFalse(); - TaskExecutor actual = registration.getTaskExecutor(this.fallback, this.customizer); - assertThat(actual).isSameAs(fallbackTaskExecutor); + assertThat(registration.hasExecutor()).isFalse(); + Executor actual = registration.getExecutor(this.fallback, this.customizer); + assertThat(actual).isSameAs(fallbackExecutor); verify(this.fallback).get(); - verify(this.customizer).accept(fallbackTaskExecutor); + verify(this.customizer).accept(fallbackExecutor); } @Test @@ -65,45 +65,44 @@ void emptyRegistrationDoesNotHaveInterceptors() { void taskRegistrationCreatesDefaultInstance() { ChannelRegistration registration = new ChannelRegistration(); registration.taskExecutor(); - assertThat(registration.hasTaskExecutor()).isTrue(); - TaskExecutor taskExecutor = registration.getTaskExecutor(this.fallback, this.customizer); - assertThat(taskExecutor).isInstanceOf(ThreadPoolTaskExecutor.class); + assertThat(registration.hasExecutor()).isTrue(); + Executor executor = registration.getExecutor(this.fallback, this.customizer); + assertThat(executor).isInstanceOf(ThreadPoolTaskExecutor.class); verifyNoInteractions(this.fallback); - verify(this.customizer).accept(taskExecutor); + verify(this.customizer).accept(executor); } @Test - void taskRegistrationWithExistingThreadPoolTaskExecutor() { - ThreadPoolTaskExecutor existingTaskExecutor = mock(ThreadPoolTaskExecutor.class); + void taskRegistrationWithExistingThreadPoolTaskExecutorDoesNotInvokeCustomizer() { + ThreadPoolTaskExecutor existingExecutor = mock(ThreadPoolTaskExecutor.class); ChannelRegistration registration = new ChannelRegistration(); - registration.taskExecutor(existingTaskExecutor); - assertThat(registration.hasTaskExecutor()).isTrue(); - TaskExecutor taskExecutor = registration.getTaskExecutor(this.fallback, this.customizer); - assertThat(taskExecutor).isSameAs(existingTaskExecutor); - verifyNoInteractions(this.fallback); - verify(this.customizer).accept(taskExecutor); + registration.taskExecutor(existingExecutor); + assertThat(registration.hasExecutor()).isTrue(); + Executor executor = registration.getExecutor(this.fallback, this.customizer); + assertThat(executor).isSameAs(existingExecutor); + verifyNoInteractions(this.fallback, this.customizer); } @Test void configureExecutor() { ChannelRegistration registration = new ChannelRegistration(); - TaskExecutor taskExecutor = mock(TaskExecutor.class); - registration.executor(taskExecutor); - assertThat(registration.hasTaskExecutor()).isTrue(); - TaskExecutor taskExecutor1 = registration.getTaskExecutor(this.fallback, this.customizer); - assertThat(taskExecutor1).isSameAs(taskExecutor); + Executor executor = mock(Executor.class); + registration.executor(executor); + assertThat(registration.hasExecutor()).isTrue(); + Executor actualExecutor = registration.getExecutor(this.fallback, this.customizer); + assertThat(actualExecutor).isSameAs(executor); verifyNoInteractions(this.fallback, this.customizer); } @Test void configureExecutorTakesPrecedenceOverTaskRegistration() { ChannelRegistration registration = new ChannelRegistration(); - TaskExecutor taskExecutor = mock(TaskExecutor.class); - registration.executor(taskExecutor); + Executor executor = mock(Executor.class); + registration.executor(executor); ThreadPoolTaskExecutor ignored = mock(ThreadPoolTaskExecutor.class); registration.taskExecutor(ignored); - assertThat(registration.hasTaskExecutor()).isTrue(); - assertThat(registration.getTaskExecutor(this.fallback, this.customizer)).isSameAs(taskExecutor); + assertThat(registration.hasExecutor()).isTrue(); + assertThat(registration.getExecutor(this.fallback, this.customizer)).isSameAs(executor); verifyNoInteractions(ignored, this.fallback, this.customizer); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/config/MessageBrokerConfigurationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/config/MessageBrokerConfigurationTests.java index 34ca2761b556..3445bb0a32c7 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/config/MessageBrokerConfigurationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/config/MessageBrokerConfigurationTests.java @@ -22,6 +22,7 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; import org.junit.jupiter.api.Test; @@ -31,7 +32,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.support.StaticApplicationContext; import org.springframework.core.Ordered; -import org.springframework.core.task.TaskExecutor; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; @@ -599,20 +599,20 @@ public TestController subscriptionController() { @Override @Bean - public AbstractSubscribableChannel clientInboundChannel(TaskExecutor clientInboundChannelExecutor) { + public AbstractSubscribableChannel clientInboundChannel(Executor clientInboundChannelExecutor) { return new TestChannel(); } @Override @Bean - public AbstractSubscribableChannel clientOutboundChannel(TaskExecutor clientOutboundChannelExecutor) { + public AbstractSubscribableChannel clientOutboundChannel(Executor clientOutboundChannelExecutor) { return new TestChannel(); } @Override @Bean public AbstractSubscribableChannel brokerChannel(AbstractSubscribableChannel clientInboundChannel, - AbstractSubscribableChannel clientOutboundChannel, TaskExecutor brokerChannelExecutor) { + AbstractSubscribableChannel clientOutboundChannel, Executor brokerChannelExecutor) { return new TestChannel(); } } @@ -688,21 +688,21 @@ protected void configureMessageBroker(MessageBrokerRegistry registry) { @Override @Bean - public AbstractSubscribableChannel clientInboundChannel(TaskExecutor clientInboundChannelExecutor) { + public AbstractSubscribableChannel clientInboundChannel(Executor clientInboundChannelExecutor) { // synchronous return new ExecutorSubscribableChannel(null); } @Override @Bean - public AbstractSubscribableChannel clientOutboundChannel(TaskExecutor clientOutboundChannelExecutor) { + public AbstractSubscribableChannel clientOutboundChannel(Executor clientOutboundChannelExecutor) { return new TestChannel(); } @Override @Bean public AbstractSubscribableChannel brokerChannel(AbstractSubscribableChannel clientInboundChannel, - AbstractSubscribableChannel clientOutboundChannel, TaskExecutor brokerChannelExecutor) { + AbstractSubscribableChannel clientOutboundChannel, Executor brokerChannelExecutor) { // synchronous return new ExecutorSubscribableChannel(null); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/SplittingStompEncoderTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/SplittingStompEncoderTests.java new file mode 100644 index 000000000000..a5d264067132 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/SplittingStompEncoderTests.java @@ -0,0 +1,280 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.messaging.simp.stomp; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.lang.Nullable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link SplittingStompEncoder}. + * + * @author Injae Kim + * @author Rossen Stoyanchev + */ +public class SplittingStompEncoderTests { + + private static final StompEncoder ENCODER = new StompEncoder(); + + public static final byte[] EMPTY_PAYLOAD = new byte[0]; + + + @Test + public void encodeFrameWithNoHeadersAndNoBody() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.DISCONNECT); + List actual = splittingEncoder(null).encode(headers.getMessageHeaders(), EMPTY_PAYLOAD); + + assertThat(toAggregatedString(actual)).isEqualTo("DISCONNECT\n\n\0"); + assertThat(actual.size()).isOne(); + } + + @Test + public void encodeFrameWithNoHeadersAndNoBodySplitTwoFrames() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.DISCONNECT); + List actual = splittingEncoder(7).encode(headers.getMessageHeaders(), EMPTY_PAYLOAD); + + assertThat(toAggregatedString(actual)).isEqualTo("DISCONNECT\n\n\0"); + assertThat(actual.size()).isEqualTo(2); + } + + @Test + public void encodeFrameWithNoHeadersAndNoBodySplitMultipleFrames() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.DISCONNECT); + List actual = splittingEncoder(3).encode(headers.getMessageHeaders(), EMPTY_PAYLOAD); + + assertThat(toAggregatedString(actual)).isEqualTo("DISCONNECT\n\n\0"); + assertThat(actual.size()).isEqualTo(5); + } + + @Test + public void encodeFrameWithHeaders() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.CONNECT); + headers.setAcceptVersion("1.2"); + headers.setHost("github.org"); + + List actual = splittingEncoder(null).encode(headers.getMessageHeaders(), EMPTY_PAYLOAD); + String output = toAggregatedString(actual); + + List list = List.of( + "CONNECT\naccept-version:1.2\nhost:github.org\n\n\0", + "CONNECT\nhost:github.org\naccept-version:1.2\n\n\0"); + + assertThat(list).contains(output); + assertThat(actual.size()).isOne(); + } + + @Test + public void encodeFrameWithHeadersSplitTwoFrames() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.CONNECT); + headers.setAcceptVersion("1.2"); + headers.setHost("github.org"); + + List actual = splittingEncoder(30).encode(headers.getMessageHeaders(), EMPTY_PAYLOAD); + String output = toAggregatedString(actual); + + assertThat("CONNECT\naccept-version:1.2\nhost:github.org\n\n\0".equals(output) || + "CONNECT\nhost:github.org\naccept-version:1.2\n\n\0".equals(output)).isTrue(); + assertThat(actual.size()).isEqualTo(2); + } + + @Test + public void encodeFrameWithHeadersSplitMultipleFrames() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.CONNECT); + headers.setAcceptVersion("1.2"); + headers.setHost("github.org"); + + List actual = splittingEncoder(10).encode(headers.getMessageHeaders(), EMPTY_PAYLOAD); + String output = toAggregatedString(actual); + + List list = List.of( + "CONNECT\naccept-version:1.2\nhost:github.org\n\n\0", + "CONNECT\nhost:github.org\naccept-version:1.2\n\n\0"); + + assertThat(list).contains(output); + assertThat(actual.size()).isEqualTo(5); + } + + @Test + public void encodeFrameWithHeadersThatShouldBeEscaped() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.DISCONNECT); + headers.addNativeHeader("a:\r\n\\b", "alpha:bravo\r\n\\"); + + List actual = splittingEncoder(null).encode(headers.getMessageHeaders(), EMPTY_PAYLOAD); + String output = toAggregatedString(actual); + + assertThat(output).isEqualTo("DISCONNECT\na\\c\\r\\n\\\\b:alpha\\cbravo\\r\\n\\\\\n\n\0"); + assertThat(actual.size()).isOne(); + } + + @Test + public void encodeFrameWithHeadersThatShouldBeEscapedSplitTwoFrames() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.DISCONNECT); + headers.addNativeHeader("a:\r\n\\b", "alpha:bravo\r\n\\"); + + List actual = splittingEncoder(30).encode(headers.getMessageHeaders(), EMPTY_PAYLOAD); + String output = toAggregatedString(actual); + + assertThat(output).isEqualTo("DISCONNECT\na\\c\\r\\n\\\\b:alpha\\cbravo\\r\\n\\\\\n\n\0"); + assertThat(actual.size()).isEqualTo(2); + } + + + @Test + public void encodeFrameWithHeadersThatShouldBeEscapedSplitMultipleFrames() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.DISCONNECT); + headers.addNativeHeader("a:\r\n\\b", "alpha:bravo\r\n\\"); + + List actual = splittingEncoder(10).encode(headers.getMessageHeaders(), EMPTY_PAYLOAD); + String output = toAggregatedString(actual); + + assertThat(output).isEqualTo("DISCONNECT\na\\c\\r\\n\\\\b:alpha\\cbravo\\r\\n\\\\\n\n\0"); + assertThat(actual.size()).isEqualTo(5); + } + + + @Test + public void encodeFrameWithHeadersBody() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.SEND); + headers.addNativeHeader("a", "alpha"); + + List actual = splittingEncoder(null).encode(headers.getMessageHeaders(), "Message body".getBytes()); + String output = toAggregatedString(actual); + + assertThat(output).isEqualTo("SEND\na:alpha\ncontent-length:12\n\nMessage body\0"); + assertThat(actual.size()).isOne(); + } + + @Test + public void encodeFrameWithHeadersBodySplitTwoFrames() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.SEND); + headers.addNativeHeader("a", "alpha"); + + List actual = splittingEncoder(30).encode(headers.getMessageHeaders(), "Message body".getBytes()); + String output = toAggregatedString(actual); + + assertThat(output).isEqualTo("SEND\na:alpha\ncontent-length:12\n\nMessage body\0"); + assertThat(actual.size()).isEqualTo(2); + } + + @Test + public void encodeFrameWithHeadersBodySplitMultipleFrames() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.SEND); + headers.addNativeHeader("a", "alpha"); + + List actual = splittingEncoder(10).encode(headers.getMessageHeaders(), "Message body".getBytes()); + String output = toAggregatedString(actual); + + assertThat(output).isEqualTo("SEND\na:alpha\ncontent-length:12\n\nMessage body\0"); + assertThat(actual.size()).isEqualTo(5); + } + + @Test + public void encodeFrameWithContentLengthPresent() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.SEND); + headers.setContentLength(12); + + List actual = splittingEncoder(null).encode(headers.getMessageHeaders(), "Message body".getBytes()); + String output = toAggregatedString(actual); + + assertThat(output).isEqualTo("SEND\ncontent-length:12\n\nMessage body\0"); + assertThat(actual.size()).isOne(); + } + + @Test + public void encodeFrameWithContentLengthPresentSplitTwoFrames() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.SEND); + headers.setContentLength(12); + + List actual = splittingEncoder(20).encode(headers.getMessageHeaders(), "Message body".getBytes()); + String output = toAggregatedString(actual); + + assertThat(output).isEqualTo("SEND\ncontent-length:12\n\nMessage body\0"); + assertThat(actual.size()).isEqualTo(2); + } + + @Test + public void encodeFrameWithContentLengthPresentSplitMultipleFrames() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.SEND); + headers.setContentLength(12); + + List actual = splittingEncoder(10).encode(headers.getMessageHeaders(), "Message body".getBytes()); + String output = toAggregatedString(actual); + + assertThat(output).isEqualTo("SEND\ncontent-length:12\n\nMessage body\0"); + assertThat(actual.size()).isEqualTo(4); + } + + @Test + public void sameLengthAndBufferSizeLimit() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.SEND); + headers.addNativeHeader("a", "1234"); + + List actual = splittingEncoder(44).encode(headers.getMessageHeaders(), "Message body".getBytes()); + String output = toAggregatedString(actual); + + assertThat(output).isEqualTo("SEND\na:1234\ncontent-length:12\n\nMessage body\0"); + assertThat(actual.size()).isOne(); + } + + @Test + public void lengthAndBufferSizeLimitExactlySplitTwoFrames() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.SEND); + headers.addNativeHeader("a", "1234"); + + List actual = splittingEncoder(22).encode(headers.getMessageHeaders(), "Message body".getBytes()); + String output = toAggregatedString(actual); + + assertThat(output).isEqualTo("SEND\na:1234\ncontent-length:12\n\nMessage body\0"); + assertThat(actual.size()).isEqualTo(2); + } + + @Test + public void lengthAndBufferSizeLimitExactlySplitMultipleFrames() { + StompHeaderAccessor headers = StompHeaderAccessor.create(StompCommand.SEND); + headers.addNativeHeader("a", "1234"); + + List actual = splittingEncoder(11).encode(headers.getMessageHeaders(), "Message body".getBytes()); + String output = toAggregatedString(actual); + + assertThat(output).isEqualTo("SEND\na:1234\ncontent-length:12\n\nMessage body\0"); + assertThat(actual.size()).isEqualTo(4); + } + + @Test + public void bufferSizeLimitShouldBePositive() { + assertThatThrownBy(() -> splittingEncoder(0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> splittingEncoder(-1)).isInstanceOf(IllegalArgumentException.class); + } + + private static SplittingStompEncoder splittingEncoder(@Nullable Integer bufferSizeLimit) { + return new SplittingStompEncoder(ENCODER, (bufferSizeLimit != null ? bufferSizeLimit : 64 * 1024)); + } + + private static String toAggregatedString(List actual) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + actual.forEach(outputStream::writeBytes); + return outputStream.toString(StandardCharsets.UTF_8); + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/user/UserDestinationMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/user/UserDestinationMessageHandlerTests.java index e1ec61dd8905..cfcfc1d08d71 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/user/UserDestinationMessageHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/user/UserDestinationMessageHandlerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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.messaging.simp.user; import java.nio.charset.StandardCharsets; +import java.util.Set; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -98,6 +99,26 @@ void handleMessage() { assertThat(accessor.getFirstNativeHeader(ORIGINAL_DESTINATION)).isEqualTo("/user/queue/foo"); } + @Test + @SuppressWarnings("rawtypes") + void handleMessageWithoutSessionIds() { + UserDestinationResolver resolver = mock(); + Message message = createWith(SimpMessageType.MESSAGE, "joe", null, "/user/joe/queue/foo"); + UserDestinationResult result = new UserDestinationResult("/queue/foo-user123", Set.of("/queue/foo-user123"), "/user/queue/foo", "joe"); + given(resolver.resolveDestination(message)).willReturn(result); + + given(this.brokerChannel.send(Mockito.any(Message.class))).willReturn(true); + UserDestinationMessageHandler handler = new UserDestinationMessageHandler(new StubMessageChannel(), this.brokerChannel, resolver); + handler.handleMessage(message); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Message.class); + Mockito.verify(this.brokerChannel).send(captor.capture()); + + SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.wrap(captor.getValue()); + assertThat(accessor.getDestination()).isEqualTo("/queue/foo-user123"); + assertThat(accessor.getFirstNativeHeader(ORIGINAL_DESTINATION)).isEqualTo("/user/queue/foo"); + } + @Test @SuppressWarnings("rawtypes") void handleMessageWithoutActiveSession() { diff --git a/spring-messaging/src/test/java/org/springframework/messaging/support/MessageHeaderAccessorTests.java b/spring-messaging/src/test/java/org/springframework/messaging/support/MessageHeaderAccessorTests.java index afe8edea4c82..370eff28676c 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/support/MessageHeaderAccessorTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/support/MessageHeaderAccessorTests.java @@ -47,6 +47,30 @@ void newEmptyHeaders() { assertThat(accessor.toMap()).isEmpty(); } + @Test + void fromEmptyMap() { + MessageHeaderAccessor accessor = MessageHeaderAccessor.fromMap(Collections.emptyMap()); + assertThat(accessor.toMap()).isEmpty(); + } + + @Test + void fromNullMap() { + MessageHeaderAccessor accessor = MessageHeaderAccessor.fromMap(null); + assertThat(accessor.toMap()).isEmpty(); + } + + @Test + void fromEmptyMessageHeaders() { + MessageHeaderAccessor accessor = MessageHeaderAccessor.fromMessageHeaders(new MessageHeaders(Collections.emptyMap())); + assertThat(accessor.toMap()).isEmpty(); + } + + @Test + void fromNullMessageHeaders() { + MessageHeaderAccessor accessor = MessageHeaderAccessor.fromMessageHeaders(null); + assertThat(accessor.toMap()).isEmpty(); + } + @Test void existingHeaders() { Map map = new HashMap<>(); @@ -62,6 +86,32 @@ void existingHeaders() { assertThat(actual.get("bar")).isEqualTo("baz"); } + @Test + void fromMapWithExistingHeaders() { + Map map = new HashMap<>(); + map.put("foo", "bar"); + map.put("bar", "baz"); + MessageHeaderAccessor accessor = MessageHeaderAccessor.fromMap(map); + MessageHeaders actual = accessor.getMessageHeaders(); + + assertThat(actual).hasSize(3); + assertThat(actual.get("foo")).isEqualTo("bar"); + assertThat(actual.get("bar")).isEqualTo("baz"); + } + + @Test + void fromMessageHeaderWithExistingHeaders() { + Map map = new HashMap<>(); + map.put("foo", "bar"); + map.put("bar", "baz"); + MessageHeaderAccessor accessor = MessageHeaderAccessor.fromMessageHeaders(new MessageHeaders(map)); + MessageHeaders actual = accessor.getMessageHeaders(); + + assertThat(actual).hasSize(3); + assertThat(actual.get("foo")).isEqualTo("bar"); + assertThat(actual.get("bar")).isEqualTo("baz"); + } + @Test void existingHeadersModification() throws InterruptedException { Map map = new HashMap<>(); diff --git a/spring-messaging/src/test/proto/sample.proto b/spring-messaging/src/test/proto/sample.proto index c303ef2894cf..74517b05b66a 100644 --- a/spring-messaging/src/test/proto/sample.proto +++ b/spring-messaging/src/test/proto/sample.proto @@ -1,4 +1,4 @@ -option java_package = "org.springframework.protobuf.messaging"; +option java_package = "org.springframework.messaging.protobuf"; option java_outer_classname = "OuterSample"; option java_multiple_files = true; diff --git a/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateJdbcException.java b/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateJdbcException.java index 1f02fdb299ad..3e3674502cbb 100644 --- a/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateJdbcException.java +++ b/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateJdbcException.java @@ -42,6 +42,7 @@ public HibernateJdbcException(JDBCException ex) { /** * Return the underlying SQLException. */ + @SuppressWarnings("NullAway") public SQLException getSQLException() { return ((JDBCException) getCause()).getSQLException(); } @@ -50,6 +51,7 @@ public SQLException getSQLException() { * Return the SQL that led to the problem. */ @Nullable + @SuppressWarnings("NullAway") public String getSql() { return ((JDBCException) getCause()).getSQL(); } diff --git a/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateQueryException.java b/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateQueryException.java index edd4d665dd14..1122c91a2498 100644 --- a/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateQueryException.java +++ b/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateQueryException.java @@ -40,6 +40,7 @@ public HibernateQueryException(QueryException ex) { * Return the HQL query string that was invalid. */ @Nullable + @SuppressWarnings("NullAway") public String getQueryString() { return ((QueryException) getCause()).getQueryString(); } diff --git a/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateTemplate.java b/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateTemplate.java index 74d662f3dc5f..51ef7fd4620b 100644 --- a/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateTemplate.java +++ b/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateTemplate.java @@ -947,6 +947,7 @@ public List findByNamedQueryAndNamedParam(String queryName, String paramName, @Deprecated @Override + @SuppressWarnings("NullAway") public List findByNamedQueryAndNamedParam( String queryName, @Nullable String[] paramNames, @Nullable Object[] values) throws DataAccessException { diff --git a/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateTransactionManager.java b/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateTransactionManager.java index d338f36444fd..fd52e4e4e280 100644 --- a/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateTransactionManager.java +++ b/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateTransactionManager.java @@ -189,11 +189,11 @@ protected final SessionFactory obtainSessionFactory() { /** * Set the JDBC DataSource that this instance should manage transactions for. - * The DataSource should match the one used by the Hibernate SessionFactory: + *

    The DataSource should match the one used by the Hibernate SessionFactory: * for example, you could specify the same JNDI DataSource for both. *

    If the SessionFactory was configured with LocalDataSourceConnectionProvider, * i.e. by Spring's LocalSessionFactoryBean with a specified "dataSource", - * the DataSource will be auto-detected: You can still explicitly specify the + * the DataSource will be auto-detected. You can still explicitly specify the * DataSource, but you don't need to in this case. *

    A transactional JDBC Connection for this DataSource will be provided to * application code accessing this DataSource directly via DataSourceUtils @@ -210,7 +210,7 @@ protected final SessionFactory obtainSessionFactory() { * for the actual target DataSource. Alternatively, consider switching * {@link #setPrepareConnection "prepareConnection"} to {@code false}. * In both cases, this transaction manager will not eagerly acquire a - * JDBC Connection for each Hibernate Session anymore (as of Spring 5.1). + * JDBC Connection for each Hibernate Session. * @see #setAutodetectDataSource * @see TransactionAwareDataSourceProxy * @see org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy @@ -290,7 +290,7 @@ public void setAllowResultAccessAfterCompletion(boolean allowResultAccessAfterCo * {@link TransactionSynchronizationManager} * check preceding it). *

    Default is "false", i.e. using a Spring-managed Session: taking the current - * thread-bound Session if available (e.g. in an Open-Session-in-View scenario), + * thread-bound Session if available (for example, in an Open-Session-in-View scenario), * creating a new Session for the current transaction otherwise. *

    Switch this flag to "true" in order to enforce use of a Hibernate-managed Session. * Note that this requires {@link SessionFactory#getCurrentSession()} @@ -313,7 +313,7 @@ public void setHibernateManagedSession(boolean hibernateManagedSession) { /** * Specify a callback for customizing every Hibernate {@code Session} resource * created for a new transaction managed by this {@code HibernateTransactionManager}. - *

    This enables convenient customizations for application purposes, e.g. + *

    This enables convenient customizations for application purposes, for example, * setting Hibernate filters. * @since 5.3 * @see Session#enableFilter diff --git a/spring-orm/src/main/java/org/springframework/orm/hibernate5/LocalSessionFactoryBean.java b/spring-orm/src/main/java/org/springframework/orm/hibernate5/LocalSessionFactoryBean.java index e8c41872ee19..94517a0a2a04 100644 --- a/spring-orm/src/main/java/org/springframework/orm/hibernate5/LocalSessionFactoryBean.java +++ b/spring-orm/src/main/java/org/springframework/orm/hibernate5/LocalSessionFactoryBean.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -40,8 +40,10 @@ import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.context.ResourceLoaderAware; +import org.springframework.core.InfrastructureProxy; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; @@ -79,7 +81,8 @@ * @see org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean */ public class LocalSessionFactoryBean extends HibernateExceptionTranslator - implements FactoryBean, ResourceLoaderAware, BeanFactoryAware, InitializingBean, DisposableBean { + implements FactoryBean, ResourceLoaderAware, BeanFactoryAware, + InitializingBean, SmartInitializingSingleton, DisposableBean { @Nullable private DataSource dataSource; @@ -382,7 +385,7 @@ public void setPackagesToScan(String... packagesToScan) { /** * Specify an asynchronous executor for background bootstrapping, - * e.g. a {@link org.springframework.core.task.SimpleAsyncTaskExecutor}. + * for example, a {@link org.springframework.core.task.SimpleAsyncTaskExecutor}. *

    {@code SessionFactory} initialization will then switch into background * bootstrap mode, with a {@code SessionFactory} proxy immediately returned for * injection purposes instead of waiting for Hibernate's bootstrapping to complete. @@ -390,6 +393,8 @@ public void setPackagesToScan(String... packagesToScan) { * then block until Hibernate's bootstrapping completed, if not ready by then. * For maximum benefit, make sure to avoid early {@code SessionFactory} calls * in init methods of related beans, even for metadata introspection purposes. + *

    As of 6.2, Hibernate initialization is enforced before context refresh + * completion, waiting for asynchronous bootstrapping to complete by then. * @since 4.3 * @see LocalSessionFactoryBuilder#buildSessionFactory(AsyncTaskExecutor) */ @@ -411,7 +416,7 @@ public void setHibernateIntegrators(Integrator... hibernateIntegrators) { } /** - * Specify a Hibernate {@link MetadataSources} service to use (e.g. reusing an + * Specify a Hibernate {@link MetadataSources} service to use (for example, reusing an * existing one), potentially populated with a custom Hibernate bootstrap * {@link org.hibernate.service.ServiceRegistry} as well. * @since 4.3 @@ -600,12 +605,20 @@ public void afterPropertiesSet() throws IOException { this.sessionFactory = buildSessionFactory(sfb); } + @Override + public void afterSingletonsInstantiated() { + // Enforce completion of asynchronous Hibernate initialization before context refresh completion. + if (this.sessionFactory instanceof InfrastructureProxy proxy) { + proxy.getWrappedObject(); + } + } + /** * Subclasses can override this method to perform custom initialization * of the SessionFactory instance, creating it via the given Configuration * object that got prepared by this LocalSessionFactoryBean. *

    The default implementation invokes LocalSessionFactoryBuilder's buildSessionFactory. - * A custom implementation could prepare the instance in a specific way (e.g. applying + * A custom implementation could prepare the instance in a specific way (for example, applying * a custom ServiceRegistry) or use a custom SessionFactoryImpl subclass. * @param sfb a LocalSessionFactoryBuilder prepared by this LocalSessionFactoryBean * @return the SessionFactory instance diff --git a/spring-orm/src/main/java/org/springframework/orm/hibernate5/LocalSessionFactoryBuilder.java b/spring-orm/src/main/java/org/springframework/orm/hibernate5/LocalSessionFactoryBuilder.java index 4ae545255fd1..764e8f97d9a3 100644 --- a/spring-orm/src/main/java/org/springframework/orm/hibernate5/LocalSessionFactoryBuilder.java +++ b/spring-orm/src/main/java/org/springframework/orm/hibernate5/LocalSessionFactoryBuilder.java @@ -75,7 +75,7 @@ * adding {@link SpringSessionContext} as a default and providing convenient ways * to specify a JDBC {@link DataSource} and an application class loader. * - *

    This is designed for programmatic use, e.g. in {@code @Bean} factory methods; + *

    This is designed for programmatic use, for example, in {@code @Bean} factory methods; * consider using {@link LocalSessionFactoryBean} for XML bean definition files. * Typically combined with {@link HibernateTransactionManager} for declarative * transactions against the {@code SessionFactory} and its JDBC {@code DataSource}. @@ -159,7 +159,7 @@ public LocalSessionFactoryBuilder(@Nullable DataSource dataSource, ResourceLoade * @param dataSource the JDBC DataSource that the resulting Hibernate SessionFactory should be using * (may be {@code null}) * @param resourceLoader the ResourceLoader to load application classes from - * @param metadataSources the Hibernate MetadataSources service to use (e.g. reusing an existing one) + * @param metadataSources the Hibernate MetadataSources service to use (for example, reusing an existing one) * @since 4.3 */ public LocalSessionFactoryBuilder( @@ -392,7 +392,7 @@ private boolean matchesEntityTypeFilter(MetadataReader reader, MetadataReaderFac /** * Build the Hibernate {@code SessionFactory} through background bootstrapping, * using the given executor for a parallel initialization phase - * (e.g. a {@link org.springframework.core.task.SimpleAsyncTaskExecutor}). + * (for example, a {@link org.springframework.core.task.SimpleAsyncTaskExecutor}). *

    {@code SessionFactory} initialization will then switch into background * bootstrap mode, with a {@code SessionFactory} proxy immediately returned for * injection purposes instead of waiting for Hibernate's bootstrapping to complete. diff --git a/spring-orm/src/main/java/org/springframework/orm/hibernate5/SpringBeanContainer.java b/spring-orm/src/main/java/org/springframework/orm/hibernate5/SpringBeanContainer.java index 7d05bce072eb..f4c0b4149f74 100644 --- a/spring-orm/src/main/java/org/springframework/orm/hibernate5/SpringBeanContainer.java +++ b/spring-orm/src/main/java/org/springframework/orm/hibernate5/SpringBeanContainer.java @@ -41,7 +41,7 @@ *

    Auto-configured by {@link LocalSessionFactoryBean#setBeanFactory}, * programmatically supported via {@link LocalSessionFactoryBuilder#setBeanContainer}, * and manually configurable through a "hibernate.resource.beans.container" entry - * in JPA properties, e.g.: + * in JPA properties, for example: * *

      * <bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
    diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/AbstractEntityManagerFactoryBean.java b/spring-orm/src/main/java/org/springframework/orm/jpa/AbstractEntityManagerFactoryBean.java
    index 60cf247d0916..94c88ec65fdf 100644
    --- a/spring-orm/src/main/java/org/springframework/orm/jpa/AbstractEntityManagerFactoryBean.java
    +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/AbstractEntityManagerFactoryBean.java
    @@ -1,5 +1,5 @@
     /*
    - * Copyright 2002-2023 the original author or authors.
    + * Copyright 2002-2024 the original author 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,7 @@
     import org.springframework.beans.factory.DisposableBean;
     import org.springframework.beans.factory.FactoryBean;
     import org.springframework.beans.factory.InitializingBean;
    +import org.springframework.beans.factory.SmartInitializingSingleton;
     import org.springframework.core.task.AsyncTaskExecutor;
     import org.springframework.dao.DataAccessException;
     import org.springframework.dao.support.PersistenceExceptionTranslator;
    @@ -79,7 +80,7 @@
      * interface, as autodetected by Spring's
      * {@link org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor},
      * for AOP-based translation of native exceptions to Spring DataAccessExceptions.
    - * Hence, the presence of e.g. LocalEntityManagerFactoryBean automatically enables
    + * Hence, the presence of, for example, LocalEntityManagerFactoryBean automatically enables
      * a PersistenceExceptionTranslationPostProcessor to translate JPA exceptions.
      *
      * @author Juergen Hoeller
    @@ -90,8 +91,9 @@
      */
     @SuppressWarnings("serial")
     public abstract class AbstractEntityManagerFactoryBean implements
    -		FactoryBean, BeanClassLoaderAware, BeanFactoryAware, BeanNameAware,
    -		InitializingBean, DisposableBean, EntityManagerFactoryInfo, PersistenceExceptionTranslator, Serializable {
    +		FactoryBean, BeanClassLoaderAware, BeanFactoryAware,
    +		BeanNameAware, InitializingBean, SmartInitializingSingleton, DisposableBean,
    +		EntityManagerFactoryInfo, PersistenceExceptionTranslator, Serializable {
     
     	/** Logger available to subclasses. */
     	protected final Log logger = LogFactory.getLog(getClass());
    @@ -299,7 +301,7 @@ public JpaVendorAdapter getJpaVendorAdapter() {
     	 * by the exposed {@code EntityManagerFactory}.
     	 * 

    This is an alternative to a {@code JpaVendorAdapter}-level * {@code postProcessEntityManager} implementation, enabling convenient - * customizations for application purposes, e.g. setting Hibernate filters. + * customizations for application purposes, for example, setting Hibernate filters. * @since 5.3 * @see JpaVendorAdapter#postProcessEntityManager * @see JpaTransactionManager#setEntityManagerInitializer @@ -310,7 +312,7 @@ public void setEntityManagerInitializer(Consumer entityManagerIni /** * Specify an asynchronous executor for background bootstrapping, - * e.g. a {@link org.springframework.core.task.SimpleAsyncTaskExecutor}. + * for example, a {@link org.springframework.core.task.SimpleAsyncTaskExecutor}. *

    {@code EntityManagerFactory} initialization will then switch into background * bootstrap mode, with a {@code EntityManagerFactory} proxy immediately returned for * injection purposes instead of waiting for the JPA provider's bootstrapping to complete. @@ -318,6 +320,8 @@ public void setEntityManagerInitializer(Consumer entityManagerIni * then block until the JPA provider's bootstrapping completed, if not ready by then. * For maximum benefit, make sure to avoid early {@code EntityManagerFactory} calls * in init methods of related beans, even for metadata introspection purposes. + *

    As of 6.2, JPA initialization is enforced before context refresh completion, + * waiting for asynchronous bootstrapping to complete by then. * @since 4.3 */ public void setBootstrapExecutor(@Nullable AsyncTaskExecutor bootstrapExecutor) { @@ -403,6 +407,12 @@ public void afterPropertiesSet() throws PersistenceException { this.entityManagerFactory = createEntityManagerFactoryProxy(this.nativeEntityManagerFactory); } + @Override + public void afterSingletonsInstantiated() { + // Enforce completion of asynchronous JPA initialization before context refresh completion. + getNativeEntityManagerFactory(); + } + private EntityManagerFactory buildNativeEntityManagerFactory() { EntityManagerFactory emf; try { @@ -417,7 +427,7 @@ private EntityManagerFactory buildNativeEntityManagerFactory() { if (cause != null) { String message = ex.getMessage(); String causeString = cause.toString(); - if (!message.endsWith(causeString)) { + if (message != null && !message.endsWith(causeString)) { ex = new PersistenceException(message + "; nested exception is " + causeString, cause); } } @@ -709,6 +719,7 @@ public ManagedEntityManagerFactoryInvocationHandler(AbstractEntityManagerFactory } @Override + @Nullable public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { switch (method.getName()) { case "equals" -> { @@ -729,6 +740,10 @@ else if (targetClass.isInstance(proxy)) { return proxy; } } + case "getName" -> { + // Handle JPA 3.2 getName method locally. + return this.entityManagerFactoryBean.getPersistenceUnitName(); + } } try { diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/EntityManagerFactoryUtils.java b/spring-orm/src/main/java/org/springframework/orm/jpa/EntityManagerFactoryUtils.java index 791ac10b6922..b9f38e313b44 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/EntityManagerFactoryUtils.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/EntityManagerFactoryUtils.java @@ -123,7 +123,7 @@ public static EntityManagerFactory findEntityManagerFactory( /** * Obtain a JPA EntityManager from the given factory. Is aware of a corresponding - * EntityManager bound to the current thread, e.g. when using JpaTransactionManager. + * EntityManager bound to the current thread, for example, when using JpaTransactionManager. *

    Note: Will return {@code null} if no thread-bound EntityManager found! * @param emf the EntityManagerFactory to create the EntityManager with * @return the EntityManager, or {@code null} if none found @@ -139,7 +139,7 @@ public static EntityManager getTransactionalEntityManager(EntityManagerFactory e /** * Obtain a JPA EntityManager from the given factory. Is aware of a corresponding - * EntityManager bound to the current thread, e.g. when using JpaTransactionManager. + * EntityManager bound to the current thread, for example, when using JpaTransactionManager. *

    Note: Will return {@code null} if no thread-bound EntityManager found! * @param emf the EntityManagerFactory to create the EntityManager with * @param properties the properties to be passed into the {@code createEntityManager} @@ -161,7 +161,7 @@ public static EntityManager getTransactionalEntityManager(EntityManagerFactory e /** * Obtain a JPA EntityManager from the given factory. Is aware of a corresponding - * EntityManager bound to the current thread, e.g. when using JpaTransactionManager. + * EntityManager bound to the current thread, for example, when using JpaTransactionManager. *

    Same as {@code getEntityManager}, but throwing the original PersistenceException. * @param emf the EntityManagerFactory to create the EntityManager with * @param properties the properties to be passed into the {@code createEntityManager} @@ -180,7 +180,7 @@ public static EntityManager doGetTransactionalEntityManager(EntityManagerFactory /** * Obtain a JPA EntityManager from the given factory. Is aware of a corresponding - * EntityManager bound to the current thread, e.g. when using JpaTransactionManager. + * EntityManager bound to the current thread, for example, when using JpaTransactionManager. *

    Same as {@code getEntityManager}, but throwing the original PersistenceException. * @param emf the EntityManagerFactory to create the EntityManager with * @param properties the properties to be passed into the {@code createEntityManager} @@ -433,7 +433,7 @@ public static void closeEntityManager(@Nullable EntityManager em) { /** * Callback for resource cleanup at the end of a non-JPA transaction - * (e.g. when participating in a JtaTransactionManager transaction), + * (for example, when participating in a JtaTransactionManager transaction), * fully synchronized with the ongoing transaction. * @see org.springframework.transaction.jta.JtaTransactionManager */ diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/JpaDialect.java b/spring-orm/src/main/java/org/springframework/orm/jpa/JpaDialect.java index 9d519dfbfde7..61eaa9bef2d5 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/JpaDialect.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/JpaDialect.java @@ -87,7 +87,7 @@ Object beginTransaction(EntityManager entityManager, TransactionDefinition defin /** * Prepare a JPA transaction, applying the specified semantics. Called by * EntityManagerFactoryUtils when enlisting an EntityManager in a JTA transaction - * or a locally joined transaction (e.g. after upgrading an unsynchronized + * or a locally joined transaction (for example, after upgrading an unsynchronized * EntityManager to a synchronized one). *

    An implementation can apply the read-only flag as flush mode. In that case, * a transaction data object can be returned that holds the previous flush mode diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/JpaVendorAdapter.java b/spring-orm/src/main/java/org/springframework/orm/jpa/JpaVendorAdapter.java index c7912be20810..d55a6b614b49 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/JpaVendorAdapter.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/JpaVendorAdapter.java @@ -45,7 +45,7 @@ public interface JpaVendorAdapter { /** * Return the name of the persistence provider's root package - * (e.g. "oracle.toplink.essentials"). Will be used for + * (for example, "oracle.toplink.essentials"). Will be used for * excluding provider classes from temporary class overriding. * @since 2.5.2 */ @@ -140,7 +140,7 @@ default void postProcessEntityManagerFactory(EntityManagerFactory emf) { /** * Optional callback for post-processing the native EntityManager * before active use. - *

    This can be used for setting vendor-specific parameters, e.g. + *

    This can be used for setting vendor-specific parameters, for example, * Hibernate filters, on every new EntityManager. * @since 5.3 */ diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/LocalContainerEntityManagerFactoryBean.java b/spring-orm/src/main/java/org/springframework/orm/jpa/LocalContainerEntityManagerFactoryBean.java index 23c3a8b96f21..bc66db3acf11 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/LocalContainerEntityManagerFactoryBean.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/LocalContainerEntityManagerFactoryBean.java @@ -184,7 +184,7 @@ public void setManagedTypes(PersistenceManagedTypes managedTypes) { * In particular, JPA providers may pick up annotated packages for provider-specific * annotations only when driven by {@code persistence.xml}. As of 4.1, Spring's * scan can detect annotated packages as well if supported by the given - * {@link JpaVendorAdapter} (e.g. for Hibernate). + * {@link JpaVendorAdapter} (for example, for Hibernate). *

    If no explicit {@link #setMappingResources mapping resources} have been * specified in addition to these packages, Spring's setup looks for a default * {@code META-INF/orm.xml} file in the classpath, registering it as a mapping @@ -218,7 +218,7 @@ public void setManagedClassNameFilter(ManagedClassNameFilter managedClassNameFil * Can be used on its own or in combination with entity scanning in the classpath, * in both cases avoiding {@code persistence.xml}. *

    Note that mapping resources must be relative to the classpath root, - * e.g. "META-INF/mappings.xml" or "com/mycompany/repository/mappings.xml", + * for example, "META-INF/mappings.xml" or "com/mycompany/repository/mappings.xml", * so that they can be loaded through {@code ClassLoader.getResource}. *

    If no explicit mapping resources have been specified next to * {@link #setPackagesToScan packages to scan}, Spring's setup looks for a default @@ -349,7 +349,7 @@ public void setResourceLoader(ResourceLoader resourceLoader) { @Override public void afterPropertiesSet() throws PersistenceException { PersistenceUnitManager managerToUse = this.persistenceUnitManager; - if (this.persistenceUnitManager == null) { + if (managerToUse == null) { this.internalPersistenceUnitManager.afterPropertiesSet(); managerToUse = this.internalPersistenceUnitManager; } diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/LocalEntityManagerFactoryBean.java b/spring-orm/src/main/java/org/springframework/orm/jpa/LocalEntityManagerFactoryBean.java index 1aaeb9a715f0..88b3d1128fa3 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/LocalEntityManagerFactoryBean.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/LocalEntityManagerFactoryBean.java @@ -16,11 +16,15 @@ package org.springframework.orm.jpa; +import javax.sql.DataSource; + import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.Persistence; import jakarta.persistence.PersistenceException; import jakarta.persistence.spi.PersistenceProvider; +import org.springframework.lang.Nullable; + /** * {@link org.springframework.beans.factory.FactoryBean} that creates a JPA * {@link jakarta.persistence.EntityManagerFactory} according to JPA's standard @@ -57,6 +61,42 @@ @SuppressWarnings("serial") public class LocalEntityManagerFactoryBean extends AbstractEntityManagerFactoryBean { + private static final String DATASOURCE_PROPERTY = "jakarta.persistence.dataSource"; + + + /** + * Specify the JDBC DataSource that the JPA persistence provider is supposed + * to use for accessing the database. This is an alternative to keeping the + * JDBC configuration in {@code persistence.xml}, passing in a Spring-managed + * DataSource through the "jakarta.persistence.dataSource" property instead. + *

    When configured here, the JDBC DataSource will also get autodetected by + * {@link JpaTransactionManager} for exposing JPA transactions to JDBC accessors. + * @since 6.2 + * @see #getJpaPropertyMap() + * @see JpaTransactionManager#setDataSource + */ + public void setDataSource(@Nullable DataSource dataSource) { + if (dataSource != null) { + getJpaPropertyMap().put(DATASOURCE_PROPERTY, dataSource); + } + else { + getJpaPropertyMap().remove(DATASOURCE_PROPERTY); + } + } + + /** + * Expose the JDBC DataSource from the "jakarta.persistence.dataSource" + * property, if any. + * @since 6.2 + * @see #getJpaPropertyMap() + */ + @Override + @Nullable + public DataSource getDataSource() { + return (DataSource) getJpaPropertyMap().get(DATASOURCE_PROPERTY); + } + + /** * Initialize the EntityManagerFactory for the given configuration. * @throws jakarta.persistence.PersistenceException in case of JPA initialization errors diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/SharedEntityManagerCreator.java b/spring-orm/src/main/java/org/springframework/orm/jpa/SharedEntityManagerCreator.java index ef20dfc3efcd..9dfb8c7d0eb4 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/SharedEntityManagerCreator.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/SharedEntityManagerCreator.java @@ -85,6 +85,7 @@ public abstract class SharedEntityManagerCreator { "execute", // jakarta.persistence.StoredProcedureQuery.execute() "executeUpdate", // jakarta.persistence.Query.executeUpdate() "getSingleResult", // jakarta.persistence.Query.getSingleResult() + "getSingleResultOrNull", // jakarta.persistence.Query.getSingleResultOrNull() "getResultStream", // jakarta.persistence.Query.getResultStream() "getResultList", // jakarta.persistence.Query.getResultList() "list", // org.hibernate.query.Query.list() @@ -185,7 +186,7 @@ public static EntityManager createSharedEntityManager(EntityManagerFactory emf, @SuppressWarnings("serial") private static class SharedEntityManagerInvocationHandler implements InvocationHandler, Serializable { - private final Log logger = LogFactory.getLog(getClass()); + private static final Log logger = LogFactory.getLog(SharedEntityManagerInvocationHandler.class); private final EntityManagerFactory targetFactory; diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/DefaultPersistenceUnitManager.java b/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/DefaultPersistenceUnitManager.java index 90b57f1cc407..058f5e9184d3 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/DefaultPersistenceUnitManager.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/DefaultPersistenceUnitManager.java @@ -219,7 +219,7 @@ public void setManagedTypes(PersistenceManagedTypes managedTypes) { * In particular, JPA providers may pick up annotated packages for provider-specific * annotations only when driven by {@code persistence.xml}. As of 4.1, Spring's * scan can detect annotated packages as well if supported by the given - * {@link org.springframework.orm.jpa.JpaVendorAdapter} (e.g. for Hibernate). + * {@link org.springframework.orm.jpa.JpaVendorAdapter} (for example, for Hibernate). *

    If no explicit {@link #setMappingResources mapping resources} have been * specified in addition to these packages, this manager looks for a default * {@code META-INF/orm.xml} file in the classpath, registering it as a mapping @@ -251,7 +251,7 @@ public void setManagedClassNameFilter(ManagedClassNameFilter managedClassNameFil * Can be used on its own or in combination with entity scanning in the classpath, * in both cases avoiding {@code persistence.xml}. *

    Note that mapping resources must be relative to the classpath root, - * e.g. "META-INF/mappings.xml" or "com/mycompany/repository/mappings.xml", + * for example, "META-INF/mappings.xml" or "com/mycompany/repository/mappings.xml", * so that they can be loaded through {@code ClassLoader.getResource}. *

    If no explicit mapping resources have been specified next to * {@link #setPackagesToScan packages to scan}, this manager looks for a default @@ -452,6 +452,7 @@ public void afterPropertiesSet() { * @see #obtainDefaultPersistenceUnitInfo() * @see #obtainPersistenceUnitInfo(String) */ + @SuppressWarnings("NullAway") public void preparePersistenceUnitInfos() { this.persistenceUnitInfoNames.clear(); this.persistenceUnitInfos.clear(); diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/PersistenceManagedTypesBeanRegistrationAotProcessor.java b/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/PersistenceManagedTypesBeanRegistrationAotProcessor.java index ee951bac60e8..5da0ac9866e8 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/PersistenceManagedTypesBeanRegistrationAotProcessor.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/PersistenceManagedTypesBeanRegistrationAotProcessor.java @@ -239,6 +239,7 @@ private static Class loadClass(String className, @Nullable } } + @SuppressWarnings("NullAway") private void registerForReflection(ReflectionHints reflection, @Nullable Annotation annotation, String attribute) { if (annotation == null) { return; diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/PersistenceManagedTypesScanner.java b/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/PersistenceManagedTypesScanner.java index 7ff998ad4ebb..a76b1a4b588c 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/PersistenceManagedTypesScanner.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/PersistenceManagedTypesScanner.java @@ -21,7 +21,6 @@ import java.net.URL; import java.util.ArrayList; import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -46,6 +45,7 @@ import org.springframework.core.type.filter.TypeFilter; import org.springframework.lang.Nullable; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; import org.springframework.util.ResourceUtils; /** @@ -66,7 +66,7 @@ public final class PersistenceManagedTypesScanner { private static final boolean shouldIgnoreClassFormatException = SpringProperties.getFlag(IGNORE_CLASSFORMAT_PROPERTY_NAME); - private static final Set entityTypeFilters = new LinkedHashSet<>(4); + private static final Set entityTypeFilters = CollectionUtils.newLinkedHashSet(4); static { entityTypeFilters.add(new AnnotationTypeFilter(Entity.class, false)); diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/PersistenceUnitReader.java b/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/PersistenceUnitReader.java index ddd74f19233e..85fd8d7823fd 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/PersistenceUnitReader.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/persistenceunit/PersistenceUnitReader.java @@ -157,6 +157,9 @@ public SpringPersistenceUnitInfo[] readPersistenceUnitInfos(String[] persistence Document buildDocument(ErrorHandler handler, InputStream stream) throws ParserConfigurationException, SAXException, IOException { + // This document loader is used for loading application configuration files. + // As a result, attackers would need complete write access to application configuration + // to leverage XXE attacks. This does not qualify as privilege escalation. DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); dbf.setNamespaceAware(true); DocumentBuilder parser = dbf.newDocumentBuilder(); diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/support/PersistenceAnnotationBeanPostProcessor.java b/spring-orm/src/main/java/org/springframework/orm/jpa/support/PersistenceAnnotationBeanPostProcessor.java index 6e42108c96c7..f5e17433c649 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/support/PersistenceAnnotationBeanPostProcessor.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/support/PersistenceAnnotationBeanPostProcessor.java @@ -113,7 +113,7 @@ * with the bean name used as fallback unit name if no deployed name found. * Typically, Spring's {@link org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean} * will be used for setting up such EntityManagerFactory beans. Alternatively, - * such beans may also be obtained from JNDI, e.g. using the {@code jee:jndi-lookup} + * such beans may also be obtained from JNDI, for example, using the {@code jee:jndi-lookup} * XML configuration element (with the bean name matching the requested unit name). * In both cases, the post-processor definition will look as simple as this: * @@ -359,6 +359,7 @@ public void resetBeanDefinition(String beanName) { } @Override + @Nullable public BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) { Class beanClass = registeredBean.getBeanClass(); String beanName = registeredBean.getBeanName(); @@ -857,6 +858,7 @@ private CodeBlock generateResourceToInjectCode( return CodeBlock.of("$L($L)", generatedMethod.getName(), REGISTERED_BEAN_PARAMETER); } + @SuppressWarnings("NullAway") private void generateGetEntityManagerMethod(MethodSpec.Builder method, PersistenceElement injectedElement) { String unitName = injectedElement.unitName; Properties properties = injectedElement.properties; diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/AbstractJpaVendorAdapter.java b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/AbstractJpaVendorAdapter.java index baa3a34b255d..f79bb3f5d858 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/AbstractJpaVendorAdapter.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/AbstractJpaVendorAdapter.java @@ -91,7 +91,7 @@ protected String getDatabasePlatform() { *

    NOTE: Do not set this flag to 'true' while also setting JPA's * {@code jakarta.persistence.schema-generation.database.action} property. * These two schema generation mechanisms - standard JPA versus provider-native - - * are mutually exclusive, e.g. with Hibernate 5. + * are mutually exclusive, for example, with Hibernate 5. * @see org.springframework.orm.jpa.AbstractEntityManagerFactoryBean#setJpaProperties */ public void setGenerateDdl(boolean generateDdl) { diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/EclipseLinkJpaDialect.java b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/EclipseLinkJpaDialect.java index c26b9bb27b2a..5b313816d120 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/EclipseLinkJpaDialect.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/EclipseLinkJpaDialect.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +18,8 @@ import java.sql.Connection; import java.sql.SQLException; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceException; @@ -39,14 +41,18 @@ * JDBC and JPA operations in the same transaction, with cross visibility of * their impact. If this is not needed, set the "lazyDatabaseTransaction" flag to * {@code true} or consistently declare all affected transactions as read-only. - * As of Spring 4.1.2, this will reliably avoid early JDBC Connection retrieval - * and therefore keep EclipseLink in shared cache mode. + * This will reliably avoid early JDBC Connection retrieval and therefore keep + * EclipseLink in shared cache mode. * *

    NOTE: This dialect supports custom isolation levels with limitations. - * Consistent isolation level handling is only guaranteed when all Spring transaction - * definitions specify a concrete isolation level, and as of 6.0.10 also when using - * the default isolation level with non-readOnly and non-lazy transactions. See the + * Consistent isolation level handling is only guaranteed when all Spring + * transaction definitions specify a concrete isolation level and when using the + * default isolation level with non-readOnly and non-lazy transactions; see the * {@link #setLazyDatabaseTransaction "lazyDatabaseTransaction" javadoc} for details. + * Internal locking happens for transaction isolation management in EclipseLink's + * DatabaseLogin, at the granularity of the {@code EclipseLinkJpaDialect} instance; + * for independent persistence units with different target databases, use distinct + * {@code EclipseLinkJpaDialect} instances in order to minimize the locking impact. * * @author Juergen Hoeller * @since 2.5.2 @@ -58,6 +64,8 @@ public class EclipseLinkJpaDialect extends DefaultJpaDialect { private boolean lazyDatabaseTransaction = false; + private final Lock transactionIsolationLock = new ReentrantLock(); + /** * Set whether to lazily start a database resource transaction within a @@ -94,13 +102,13 @@ public Object beginTransaction(EntityManager entityManager, TransactionDefinitio int currentIsolationLevel = definition.getIsolationLevel(); if (currentIsolationLevel != TransactionDefinition.ISOLATION_DEFAULT) { - // Pass custom isolation level on to EclipseLink's DatabaseLogin configuration - // (since Spring 4.1.2 / revised in 5.3.28) + // Pass custom isolation level on to EclipseLink's DatabaseLogin configuration. UnitOfWork uow = entityManager.unwrap(UnitOfWork.class); DatabaseLogin databaseLogin = uow.getLogin(); - // Synchronize on shared DatabaseLogin instance for consistent isolation level + // Lock around shared DatabaseLogin instance for consistent isolation level // set and reset in case of concurrent transactions with different isolation. - synchronized (databaseLogin) { + this.transactionIsolationLock.lock(); + try { int originalIsolationLevel = databaseLogin.getTransactionIsolation(); // Apply current isolation level value, if necessary. if (currentIsolationLevel != originalIsolationLevel) { @@ -116,20 +124,26 @@ public Object beginTransaction(EntityManager entityManager, TransactionDefinitio databaseLogin.setTransactionIsolation(originalIsolationLevel); } } + finally { + this.transactionIsolationLock.unlock(); + } } else if (!definition.isReadOnly() && !this.lazyDatabaseTransaction) { // Begin an early transaction to force EclipseLink to get a JDBC Connection // so that Spring can manage transactions with JDBC as well as EclipseLink. UnitOfWork uow = entityManager.unwrap(UnitOfWork.class); - DatabaseLogin databaseLogin = uow.getLogin(); - // Synchronize on shared DatabaseLogin instance for consistently picking up + // Lock around shared DatabaseLogin instance for consistently picking up // the default isolation level even in case of concurrent transactions with - // a custom isolation level (see above), as of 6.0.10 - synchronized (databaseLogin) { + // a custom isolation level (see above). + this.transactionIsolationLock.lock(); + try { entityManager.getTransaction().begin(); uow.beginEarlyTransaction(); entityManager.unwrap(Connection.class); } + finally { + this.transactionIsolationLock.unlock(); + } } else { // Regular transaction begin with lazy database transaction. @@ -143,9 +157,6 @@ else if (!definition.isReadOnly() && !this.lazyDatabaseTransaction) { public ConnectionHandle getJdbcConnection(EntityManager entityManager, boolean readOnly) throws PersistenceException, SQLException { - // As of Spring 4.1.2, we're using a custom ConnectionHandle for lazy retrieval - // of the underlying Connection (allowing for deferred internal transaction begin - // within the EclipseLink EntityManager) return new EclipseLinkConnectionHandle(entityManager); } diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaVendorAdapter.java b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaVendorAdapter.java index ad53f8604d8f..6f601c1ce80f 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaVendorAdapter.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaVendorAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,6 @@ import org.hibernate.dialect.MySQL57Dialect; import org.hibernate.dialect.MySQLDialect; import org.hibernate.dialect.Oracle12cDialect; -import org.hibernate.dialect.OracleDialect; import org.hibernate.dialect.PostgreSQL95Dialect; import org.hibernate.dialect.SQLServer2012Dialect; import org.hibernate.dialect.SQLServerDialect; @@ -54,7 +53,7 @@ * EntityManager interface, and adapts {@link AbstractJpaVendorAdapter}'s common * configuration settings. Also supports the detection of annotated packages (through * {@link org.springframework.orm.jpa.persistenceunit.SmartPersistenceUnitInfo#getManagedPackages()}), - * e.g. containing Hibernate {@link org.hibernate.annotations.FilterDef} annotations, + * for example, containing Hibernate {@link org.hibernate.annotations.FilterDef} annotations, * along with Spring-driven entity scanning which requires no {@code persistence.xml} * ({@link org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean#setPackagesToScan}). * @@ -104,7 +103,7 @@ public HibernateJpaVendorAdapter() { * new connection handling mode {@code DELAYED_ACQUISITION_AND_HOLD} in that case * unless a user-specified connection handling mode property indicates otherwise; * switch this flag to {@code false} to avoid that interference. - *

    NOTE: For a persistence unit with transaction type JTA e.g. on WebLogic, + *

    NOTE: For a persistence unit with transaction type JTA, for example, on WebLogic, * the connection release mode will never be altered from its provider default, * i.e. not be forced to {@code DELAYED_ACQUISITION_AND_HOLD} by this flag. * Alternatively, set Hibernate's "hibernate.connection.handling_mode" @@ -177,7 +176,7 @@ private Map buildJpaPropertyMap(boolean connectionReleaseOnClose * @param database the target database * @return the Hibernate database dialect class, or {@code null} if none found */ - @SuppressWarnings("deprecation") // for DerbyDialect and PostgreSQLDialect on Hibernate 6.2 + @SuppressWarnings("deprecation") // for OracleDialect on Hibernate 5.6 and DerbyDialect/PostgreSQLDialect on Hibernate 6.2 @Nullable protected Class determineDatabaseDialectClass(Database database) { if (oldDialectsPresent) { // Hibernate <6.2 @@ -204,7 +203,7 @@ protected Class determineDatabaseDialectClass(Database database) { case HANA -> HANAColumnStoreDialect.class; case HSQL -> HSQLDialect.class; case MYSQL -> MySQLDialect.class; - case ORACLE -> OracleDialect.class; + case ORACLE -> org.hibernate.dialect.OracleDialect.class; case POSTGRESQL -> org.hibernate.dialect.PostgreSQLDialect.class; case SQL_SERVER -> SQLServerDialect.class; case SYBASE -> SybaseDialect.class; diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/SpringHibernateJpaPersistenceProvider.java b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/SpringHibernateJpaPersistenceProvider.java index 511d55f0dd7d..d7c46339593e 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/SpringHibernateJpaPersistenceProvider.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/SpringHibernateJpaPersistenceProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +24,6 @@ import jakarta.persistence.spi.PersistenceUnitInfo; import org.hibernate.bytecode.enhance.spi.EnhancementContext; import org.hibernate.cfg.Configuration; -import org.hibernate.cfg.Environment; import org.hibernate.jpa.HibernatePersistenceProvider; import org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl; import org.hibernate.jpa.boot.internal.PersistenceUnitInfoDescriptor; @@ -43,16 +42,8 @@ * @since 4.1 * @see Configuration#addPackage */ -@SuppressWarnings("removal") // for Environment properties on Hibernate 6.2 class SpringHibernateJpaPersistenceProvider extends HibernatePersistenceProvider { - static { - if (NativeDetector.inNativeImage()) { - System.setProperty(Environment.BYTECODE_PROVIDER, Environment.BYTECODE_PROVIDER_NAME_NONE); - System.setProperty(Environment.USE_REFLECTION_OPTIMIZER, Boolean.FALSE.toString()); - } - } - @Override @SuppressWarnings({"rawtypes", "unchecked"}) // on Hibernate 6 public EntityManagerFactory createContainerEntityManagerFactory(PersistenceUnitInfo info, Map properties) { diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/Target_BytecodeProvider.java b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/Target_BytecodeProvider.java deleted file mode 100644 index df7aac17cf05..000000000000 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/Target_BytecodeProvider.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2002-2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.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.orm.jpa.vendor; - -import java.util.Map; -import java.util.function.Predicate; - -import com.oracle.svm.core.annotate.Substitute; -import com.oracle.svm.core.annotate.TargetClass; -import org.hibernate.bytecode.spi.ReflectionOptimizer; -import org.hibernate.property.access.spi.PropertyAccess; - -/** - * Hibernate 6.3+ substitution designed to leniently return {@code null}, as authorized by the API, to avoid throwing an - * {@code HibernateException}. - * - * @author Sebastien Deleuze - * @since 6.1 - * @see HHH-17568 - */ -@TargetClass(className = "org.hibernate.bytecode.internal.none.BytecodeProviderImpl", onlyWith = Target_BytecodeProvider.SubstituteOnlyIfPresent.class) -final class Target_BytecodeProvider { - - @Substitute - public ReflectionOptimizer getReflectionOptimizer(Class clazz, Map propertyAccessMap) { - return null; - } - - static class SubstituteOnlyIfPresent implements Predicate { - - @Override - public boolean test(String type) { - try { - Class clazz = Class.forName(type, false, getClass().getClassLoader()); - clazz.getDeclaredMethod("getReflectionOptimizer", Class.class, Map.class); - return true; - } - catch (ClassNotFoundException | NoClassDefFoundError | NoSuchMethodException ex) { - return false; - } - } - } - -} diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/Target_BytecodeProviderInitiator.java b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/Target_BytecodeProviderInitiator.java deleted file mode 100644 index 64ca048af44c..000000000000 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/Target_BytecodeProviderInitiator.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2002-2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.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.orm.jpa.vendor; - -import java.util.function.Predicate; - -import com.oracle.svm.core.annotate.Alias; -import com.oracle.svm.core.annotate.RecomputeFieldValue; -import com.oracle.svm.core.annotate.Substitute; -import com.oracle.svm.core.annotate.TargetClass; -import org.hibernate.bytecode.spi.BytecodeProvider; - -import static com.oracle.svm.core.annotate.RecomputeFieldValue.Kind; - -/** - * Hibernate substitution designed to prevent ByteBuddy reachability on native, and to enforce the - * usage of {@code org.hibernate.bytecode.internal.none.BytecodeProviderImpl} with Hibernate 6.3+. - * - * @author Sebastien Deleuze - * @since 6.1 - */ -@TargetClass(className = "org.hibernate.bytecode.internal.BytecodeProviderInitiator", onlyWith = Target_BytecodeProviderInitiator.SubstituteOnlyIfPresent.class) -final class Target_BytecodeProviderInitiator { - - @Alias - public static String BYTECODE_PROVIDER_NAME_NONE; - - @Alias - @RecomputeFieldValue(kind = Kind.FromAlias) - public static String BYTECODE_PROVIDER_NAME_DEFAULT = BYTECODE_PROVIDER_NAME_NONE; - - @Substitute - public static BytecodeProvider buildBytecodeProvider(String providerName) { - return new org.hibernate.bytecode.internal.none.BytecodeProviderImpl(); - } - - static class SubstituteOnlyIfPresent implements Predicate { - - @Override - public boolean test(String type) { - try { - Class clazz = Class.forName(type, false, getClass().getClassLoader()); - clazz.getDeclaredMethod("buildBytecodeProvider", String.class); - clazz.getField("BYTECODE_PROVIDER_NAME_NONE"); - clazz.getField("BYTECODE_PROVIDER_NAME_DEFAULT"); - return true; - } - catch (ClassNotFoundException | NoClassDefFoundError | NoSuchMethodException | NoSuchFieldException ex) { - return false; - } - } - } - -} 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 843eda5800d6..b8ff7a852e1e 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-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/ApplicationManagedEntityManagerIntegrationTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/ApplicationManagedEntityManagerIntegrationTests.java index 6fd66258c5b7..a5332cd76e56 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/ApplicationManagedEntityManagerIntegrationTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/ApplicationManagedEntityManagerIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/ContainerManagedEntityManagerIntegrationTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/ContainerManagedEntityManagerIntegrationTests.java index 8ef556019c4d..6dd2455163b9 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/ContainerManagedEntityManagerIntegrationTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/ContainerManagedEntityManagerIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/DefaultJpaDialectTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/DefaultJpaDialectTests.java index 57030f7b7031..ac934635a2c1 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/DefaultJpaDialectTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/DefaultJpaDialectTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/EntityManagerFactoryBeanSupportTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/EntityManagerFactoryBeanSupportTests.java index 1e3a95fc5700..daa824c87684 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/EntityManagerFactoryBeanSupportTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/EntityManagerFactoryBeanSupportTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/EntityManagerFactoryUtilsTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/EntityManagerFactoryUtilsTests.java index 0a1dbbc6bb88..9e6067d91dfb 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/EntityManagerFactoryUtilsTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/EntityManagerFactoryUtilsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/LocalContainerEntityManagerFactoryBeanTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/LocalContainerEntityManagerFactoryBeanTests.java index 4d48ff809008..a31b2e646c3a 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/LocalContainerEntityManagerFactoryBeanTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/LocalContainerEntityManagerFactoryBeanTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/eclipselink/EclipseLinkEntityManagerFactoryIntegrationTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/eclipselink/EclipseLinkEntityManagerFactoryIntegrationTests.java index 382fede5c3d7..bd953e0dff28 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/eclipselink/EclipseLinkEntityManagerFactoryIntegrationTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/eclipselink/EclipseLinkEntityManagerFactoryIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateEntityManagerFactoryIntegrationTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateEntityManagerFactoryIntegrationTests.java index 30f1069f6ce6..344bf983fdc0 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateEntityManagerFactoryIntegrationTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateEntityManagerFactoryIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateMultiEntityManagerFactoryIntegrationTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateMultiEntityManagerFactoryIntegrationTests.java index 029beebf7746..76b3b7f2e9d6 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateMultiEntityManagerFactoryIntegrationTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateMultiEntityManagerFactoryIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateNativeEntityManagerFactoryIntegrationTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateNativeEntityManagerFactoryIntegrationTests.java index a15cc75ca8c2..8da3a0e7b0e9 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateNativeEntityManagerFactoryIntegrationTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateNativeEntityManagerFactoryIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateNativeEntityManagerFactorySpringBeanContainerIntegrationTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateNativeEntityManagerFactorySpringBeanContainerIntegrationTests.java index ccd837c7851a..1b3ce4087c97 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateNativeEntityManagerFactorySpringBeanContainerIntegrationTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateNativeEntityManagerFactorySpringBeanContainerIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/persistenceunit/DefaultPersistenceUnitManagerTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/persistenceunit/DefaultPersistenceUnitManagerTests.java index 899053375f67..3a3e366f87af 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/persistenceunit/DefaultPersistenceUnitManagerTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/persistenceunit/DefaultPersistenceUnitManagerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/persistenceunit/PersistenceXmlParsingTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/persistenceunit/PersistenceXmlParsingTests.java index 60bdf5600621..c0c66f1ce1b8 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/persistenceunit/PersistenceXmlParsingTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/persistenceunit/PersistenceXmlParsingTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/support/PersistenceInjectionIntegrationTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/support/PersistenceInjectionIntegrationTests.java index 1dcae67ee589..656f6eee61f8 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/support/PersistenceInjectionIntegrationTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/support/PersistenceInjectionIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/support/SharedEntityManagerFactoryTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/support/SharedEntityManagerFactoryTests.java index 8c144038913b..0dfe8225bc21 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/support/SharedEntityManagerFactoryTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/support/SharedEntityManagerFactoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-orm/src/test/resources/org/springframework/orm/jpa/hibernate/hibernate-manager.xml b/spring-orm/src/test/resources/org/springframework/orm/jpa/hibernate/hibernate-manager.xml index 114d495e08c5..caa626507475 100644 --- a/spring-orm/src/test/resources/org/springframework/orm/jpa/hibernate/hibernate-manager.xml +++ b/spring-orm/src/test/resources/org/springframework/orm/jpa/hibernate/hibernate-manager.xml @@ -28,7 +28,7 @@ - + diff --git a/spring-orm/src/test/resources/org/springframework/orm/jpa/inject.xml b/spring-orm/src/test/resources/org/springframework/orm/jpa/inject.xml index 812ac80c4564..520735843b81 100644 --- a/spring-orm/src/test/resources/org/springframework/orm/jpa/inject.xml +++ b/spring-orm/src/test/resources/org/springframework/orm/jpa/inject.xml @@ -9,7 +9,7 @@ - + diff --git a/spring-oxm/src/main/java/org/springframework/oxm/jaxb/Jaxb2Marshaller.java b/spring-oxm/src/main/java/org/springframework/oxm/jaxb/Jaxb2Marshaller.java index 2d903c092cd8..efddba004567 100644 --- a/spring-oxm/src/main/java/org/springframework/oxm/jaxb/Jaxb2Marshaller.java +++ b/spring-oxm/src/main/java/org/springframework/oxm/jaxb/Jaxb2Marshaller.java @@ -603,6 +603,9 @@ private Schema loadSchema(Resource[] resources, String schemaLanguage) throws IO Assert.hasLength(schemaLanguage, "No schema language provided"); Source[] schemaSources = new Source[resources.length]; + // This parser is used to read the schema resources provided by the application. + // The parser used for reading the source is protected against XXE attacks. + // See "processSource(Source source)". SAXParserFactory saxParserFactory = this.schemaParserFactory; if (saxParserFactory == null) { saxParserFactory = SAXParserFactory.newInstance(); @@ -907,6 +910,8 @@ else if (streamSource.getReader() != null) { } try { + // By default, Spring will prevent the processing of external entities. + // This is a mitigation against XXE attacks. if (xmlReader == null) { SAXParserFactory saxParserFactory = this.sourceParserFactory; if (saxParserFactory == null) { diff --git a/spring-oxm/src/main/java/org/springframework/oxm/xstream/XStreamMarshaller.java b/spring-oxm/src/main/java/org/springframework/oxm/xstream/XStreamMarshaller.java index b8b498cbb8e2..a727bf1c2eaf 100644 --- a/spring-oxm/src/main/java/org/springframework/oxm/xstream/XStreamMarshaller.java +++ b/spring-oxm/src/main/java/org/springframework/oxm/xstream/XStreamMarshaller.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -109,9 +109,7 @@ * Therefore, it has limited namespace support. As such, it is rather unsuitable for * usage within Web Services. * - *

    This marshaller requires XStream 1.4.7 or higher, as of Spring 5.2.17. - * Note that {@link XStream} construction has been reworked in 4.0, with the - * stream driver and the class loader getting passed into XStream itself now. + *

    This marshaller requires XStream 1.4.7 or higher. * *

    As of Spring Framework 6.0, the default {@link HierarchicalStreamDriver} is * a {@link DomDriver} that uses the configured {@linkplain #setEncoding(String) @@ -641,11 +639,10 @@ protected void customizeXStream(XStream xstream) { /** * Return the native XStream delegate used by this marshaller. - *

    NOTE: This method has been marked as final as of Spring 4.0. - * It can be used to access the fully configured XStream for marshalling - * but not configuration purposes anymore. - *

    As of Spring Framework 5.1.16, creation of the {@link XStream} instance - * returned by this method is thread safe. + *

    The creation of the {@link XStream} instance returned by this method is + * thread safe. + *

    NOTE: This method is marked as final. It can be used to access + * the fully configured XStream for marshalling but not configuration purposes. */ public final XStream getXStream() { return this.xstream.obtain(); diff --git a/spring-oxm/src/test/java/org/springframework/oxm/AbstractMarshallerTests.java b/spring-oxm/src/test/java/org/springframework/oxm/AbstractMarshallerTests.java index 19a067e4805d..bd3c718fff3b 100644 --- a/spring-oxm/src/test/java/org/springframework/oxm/AbstractMarshallerTests.java +++ b/spring-oxm/src/test/java/org/springframework/oxm/AbstractMarshallerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-oxm/src/test/java/org/springframework/oxm/AbstractUnmarshallerTests.java b/spring-oxm/src/test/java/org/springframework/oxm/AbstractUnmarshallerTests.java index 4101925e059a..4a98aff68220 100644 --- a/spring-oxm/src/test/java/org/springframework/oxm/AbstractUnmarshallerTests.java +++ b/spring-oxm/src/test/java/org/springframework/oxm/AbstractUnmarshallerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-oxm/src/test/java/org/springframework/oxm/config/OxmNamespaceHandlerTests.java b/spring-oxm/src/test/java/org/springframework/oxm/config/OxmNamespaceHandlerTests.java index 6bf8e1e4732a..2ee0e036425a 100644 --- a/spring-oxm/src/test/java/org/springframework/oxm/config/OxmNamespaceHandlerTests.java +++ b/spring-oxm/src/test/java/org/springframework/oxm/config/OxmNamespaceHandlerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-oxm/src/test/java/org/springframework/oxm/jaxb/Jaxb2MarshallerTests.java b/spring-oxm/src/test/java/org/springframework/oxm/jaxb/Jaxb2MarshallerTests.java index f305d971a753..5be207251ccf 100644 --- a/spring-oxm/src/test/java/org/springframework/oxm/jaxb/Jaxb2MarshallerTests.java +++ b/spring-oxm/src/test/java/org/springframework/oxm/jaxb/Jaxb2MarshallerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-oxm/src/test/java/org/springframework/oxm/jaxb/Jaxb2UnmarshallerTests.java b/spring-oxm/src/test/java/org/springframework/oxm/jaxb/Jaxb2UnmarshallerTests.java index ad8b9701b3ca..e1a15ff96290 100644 --- a/spring-oxm/src/test/java/org/springframework/oxm/jaxb/Jaxb2UnmarshallerTests.java +++ b/spring-oxm/src/test/java/org/springframework/oxm/jaxb/Jaxb2UnmarshallerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-oxm/src/test/java/org/springframework/oxm/xstream/XStreamUnmarshallerTests.java b/spring-oxm/src/test/java/org/springframework/oxm/xstream/XStreamUnmarshallerTests.java index 847e856d58af..eea9e7caa05f 100644 --- a/spring-oxm/src/test/java/org/springframework/oxm/xstream/XStreamUnmarshallerTests.java +++ b/spring-oxm/src/test/java/org/springframework/oxm/xstream/XStreamUnmarshallerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/SingleConnectionFactory.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/SingleConnectionFactory.java index bcdd395663dc..65e9786f8b1e 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/SingleConnectionFactory.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/SingleConnectionFactory.java @@ -42,7 +42,7 @@ *

    Note that at shutdown, someone should close the underlying * {@code Connection} via the {@code close()} method. Client code will * never call close on the {@code Connection} handle if it is - * SmartConnectionFactory-aware (e.g. uses + * SmartConnectionFactory-aware (for example, uses * {@link ConnectionFactoryUtils#releaseConnection(Connection, ConnectionFactory)}). * *

    If client code will call {@link Connection#close()} in the diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/lookup/AbstractRoutingConnectionFactory.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/lookup/AbstractRoutingConnectionFactory.java index f02703476d93..900197f5c0cb 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/lookup/AbstractRoutingConnectionFactory.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/lookup/AbstractRoutingConnectionFactory.java @@ -142,6 +142,7 @@ public void afterPropertiesSet() { * @see #setTargetConnectionFactories(Map) * @see #setDefaultTargetConnectionFactory(Object) */ + @SuppressWarnings("NullAway") public void initialize() { Assert.notNull(this.targetConnectionFactories, "Property 'targetConnectionFactories' must not be null"); @@ -220,6 +221,7 @@ public ConnectionFactoryMetadata getMetadata() { * per {@link #determineCurrentLookupKey()} * @see #determineCurrentLookupKey() */ + @SuppressWarnings("NullAway") protected Mono determineTargetConnectionFactory() { Assert.state(this.resolvedConnectionFactories != null, "ConnectionFactory router not initialized"); diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DatabaseClient.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DatabaseClient.java index 4ad0cd7c818a..2d269d23b7e5 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DatabaseClient.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DatabaseClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.r2dbc.core; +import java.util.List; import java.util.Map; import java.util.function.BiFunction; import java.util.function.Consumer; @@ -56,6 +57,7 @@ * * @author Mark Paluch * @author Juergen Hoeller + * @author Brian Clozel * @since 5.3 */ public interface DatabaseClient extends ConnectionAccessor { @@ -69,7 +71,7 @@ public interface DatabaseClient extends ConnectionAccessor { /** * Specify a static {@code sql} statement to run. Contract for specifying an * SQL call along with options leading to the execution. The SQL string can - * contain either native parameter bind markers or named parameters (e.g. + * contain either native parameter bind markers or named parameters (for example, * {@literal :foo, :bar}) when {@link NamedParameterExpander} is enabled. * @param sql the SQL statement * @return a new {@link GenericExecuteSpec} @@ -82,7 +84,7 @@ public interface DatabaseClient extends ConnectionAccessor { * Specify an {@linkplain Supplier SQL supplier} that provides SQL to run. * Contract for specifying an SQL call along with options leading to * the execution. The SQL string can contain either native parameter - * bind markers or named parameters (e.g. {@literal :foo, :bar}) when + * bind markers or named parameters (for example, {@literal :foo, :bar}) when * {@link NamedParameterExpander} is enabled. *

    Accepts {@link PreparedOperation} as SQL and binding {@link Supplier}. *

    {@code DatabaseClient} implementations should defer the resolution of @@ -191,6 +193,18 @@ interface GenericExecuteSpec { */ GenericExecuteSpec bindNull(String name, Class type); + /** + * Bind the parameter values from the given source list, + * registering each as a positional parameter using their order + * in the given list as their index. + * @param source the source list of parameters, with their order + * as position and each value either a scalar value + * or a {@link io.r2dbc.spi.Parameter} + * @since 6.2 + * @see #bind(int, Object) + */ + GenericExecuteSpec bindValues(List source); + /** * Bind the parameter values from the given source map, * registering each as a parameter with the map key as name. diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DefaultDatabaseClient.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DefaultDatabaseClient.java index 6cab172001b8..d535a3989b6a 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DefaultDatabaseClient.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DefaultDatabaseClient.java @@ -24,6 +24,7 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; +import java.util.ListIterator; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiFunction; @@ -308,6 +309,18 @@ public DefaultGenericExecuteSpec bindNull(String name, Class type) { return new DefaultGenericExecuteSpec(this.byIndex, byName, this.sqlSupplier, this.filterFunction); } + @Override + public GenericExecuteSpec bindValues(List source) { + assertNotPreparedOperation(); + Assert.notNull(source, "Source list must not be null"); + Map byIndex = new LinkedHashMap<>(this.byIndex); + ListIterator listIterator = source.listIterator(); + while (listIterator.hasNext()) { + byIndex.put(listIterator.nextIndex(), resolveParameter(listIterator.next())); + } + return new DefaultGenericExecuteSpec(byIndex, this.byName, this.sqlSupplier, this.filterFunction); + } + @Override public GenericExecuteSpec bindValues(Map source) { assertNotPreparedOperation(); diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/MapBindParameterSource.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/MapBindParameterSource.java index b632dfd219cc..826666d74b4a 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/MapBindParameterSource.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/MapBindParameterSource.java @@ -75,6 +75,7 @@ public boolean hasValue(String paramName) { } @Override + @SuppressWarnings("NullAway") public Parameter getValue(String paramName) throws IllegalArgumentException { if (!hasValue(paramName)) { throw new IllegalArgumentException("No value registered for key '" + paramName + "'"); diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/NamedParameterUtils.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/NamedParameterUtils.java index 306de12f8718..f6696e8d9cd9 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/NamedParameterUtils.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/NamedParameterUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,14 +54,14 @@ abstract class NamedParameterUtils { /** - * Set of characters that qualify as comment or quotes starting characters. + * Set of characters that qualify as comment or quote starting characters. */ - private static final String[] START_SKIP = new String[] {"'", "\"", "--", "/*"}; + private static final String[] START_SKIP = {"'", "\"", "--", "/*", "`"}; /** - * Set of characters that at are the corresponding comment or quotes ending characters. + * Set of characters that are the corresponding comment or quote ending characters. */ - private static final String[] STOP_SKIP = new String[] {"'", "\"", "\n", "*/"}; + private static final String[] STOP_SKIP = {"'", "\"", "\n", "*/", "`"}; /** * Set of characters that qualify as parameter separators, @@ -275,9 +275,10 @@ private static int skipCommentsAndQuotes(char[] statement, int position) { * and in that case the placeholders will be grouped and enclosed with parentheses. * This allows for the use of "expression lists" in the SQL statement like: * {@code select id, name, state from table where (name, age) in (('John', 35), ('Ann', 50))} - *

    The parameter values passed in are used to determine the number of placeholders to - * be used for a select list. Select lists should be limited to 100 or fewer elements. - * A larger number of elements is not guaranteed to be supported by the database and + *

    The parameter values passed in are used to determine the number of + * placeholders to be used for a select list. Select lists should not be empty + * and should be limited to 100 or fewer elements. An empty list or a larger + * number of elements is not guaranteed to be supported by the database and * is strictly vendor-dependent. * @param parsedSql the parsed representation of the SQL statement * @param bindMarkersFactory the bind marker factory. @@ -308,15 +309,13 @@ public static PreparedOperation substituteNamedParameters(ParsedSql pars if (paramSource.hasValue(paramName)) { Parameter parameter = paramSource.getValue(paramName); if (parameter.getValue() instanceof Collection collection) { - Iterator entryIter = collection.iterator(); int k = 0; int counter = 0; - while (entryIter.hasNext()) { + for (Object entryItem : collection) { if (k > 0) { actualSql.append(", "); } k++; - Object entryItem = entryIter.next(); if (entryItem instanceof Object[] expressionList) { actualSql.append('('); for (int m = 0; m < expressionList.length; m++) { @@ -363,8 +362,11 @@ private static boolean isParameterSeparator(char c) { /** * Parse the SQL statement and locate any placeholders or named parameters. - * Named parameters are substituted for a native placeholder and any + *

    Named parameters are substituted for a native placeholder and any * select list is expanded to the required number of placeholders. + *

    This is a shortcut version of + * {@link #parseSqlStatement(String)} in combination with + * {@link #substituteNamedParameters(ParsedSql, BindMarkersFactory, BindParameterSource)}. * @param sql the SQL statement * @param bindMarkersFactory the bind marker factory * @param paramSource the source for named parameters 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 0175c0b1c3ca..c658352846d7 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-2021 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +153,7 @@ private static String filterBindMarker(CharSequence input) { builder.append(ch); } } - if (builder.length() == 0) { + if (builder.isEmpty()) { return ""; } return "_" + builder.toString(); diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/AbstractDatabaseClientIntegrationTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/AbstractDatabaseClientIntegrationTests.java index d0625b1bcc0a..19332ecd4033 100644 --- a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/AbstractDatabaseClientIntegrationTests.java +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/AbstractDatabaseClientIntegrationTests.java @@ -16,6 +16,7 @@ package org.springframework.r2dbc.core; +import java.util.List; import java.util.Map; import io.r2dbc.spi.ConnectionFactory; @@ -96,6 +97,25 @@ void executeInsert() { .verifyComplete(); } + @Test + void executeInsertWithList() { + DatabaseClient databaseClient = DatabaseClient.create(connectionFactory); + + databaseClient.sql("INSERT INTO legoset (id, name, manual) VALUES(:id, :name, :manual)") + .bindValues(List.of(42055, Parameters.in("SCHAUFELRADBAGGER"), Parameters.in(Integer.class))) + .fetch().rowsUpdated() + .as(StepVerifier::create) + .expectNext(1L) + .verifyComplete(); + + databaseClient.sql("SELECT id FROM legoset") + .mapValue(Integer.class) + .first() + .as(StepVerifier::create) + .assertNext(actual -> assertThat(actual).isEqualTo(42055)) + .verifyComplete(); + } + @Test void executeInsertWithMap() { DatabaseClient databaseClient = DatabaseClient.create(connectionFactory); diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/NamedParameterUtilsTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/NamedParameterUtilsTests.java index 139291ce91a5..6d23d4810371 100644 --- a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/NamedParameterUtilsTests.java +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/NamedParameterUtilsTests.java @@ -24,6 +24,8 @@ import io.r2dbc.spi.Parameters; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.r2dbc.core.binding.BindMarkersFactory; import org.springframework.r2dbc.core.binding.BindTarget; @@ -243,40 +245,41 @@ void variableAssignmentOperator() { assertThat(expand(expectedSql)).isEqualTo(expectedSql); } - @Test - void parseSqlStatementWithQuotedSingleQuote() { - String sql = "SELECT ':foo'':doo', :xxx FROM DUAL"; - - ParsedSql psql = NamedParameterUtils.parseSqlStatement(sql); - assertThat(psql.getTotalParameterCount()).isEqualTo(1); - assertThat(psql.getParameterNames()).containsExactly("xxx"); - } - - @Test - void parseSqlStatementWithQuotesAndCommentBefore() { - String sql = "SELECT /*:doo*/':foo', :xxx FROM DUAL"; - - ParsedSql psql = NamedParameterUtils.parseSqlStatement(sql); - assertThat(psql.getTotalParameterCount()).isEqualTo(1); - assertThat(psql.getParameterNames()).containsExactly("xxx"); - } - - @Test - void parseSqlStatementWithQuotesAndCommentAfter() { - String sql2 = "SELECT ':foo'/*:doo*/, :xxx FROM DUAL"; - - ParsedSql psql2 = NamedParameterUtils.parseSqlStatement(sql2); - assertThat(psql2.getTotalParameterCount()).isEqualTo(1); - assertThat(psql2.getParameterNames()).containsExactly("xxx"); + @ParameterizedTest // SPR-8280 and others + @ValueSource(strings = { + "SELECT ':foo'':doo', :xxx FROM DUAL", + "SELECT /*:doo*/':foo', :xxx FROM DUAL", + "SELECT ':foo'/*:doo*/, :xxx FROM DUAL", + "SELECT \":foo\"\":doo\", :xxx FROM DUAL", + "SELECT `:foo``:doo`, :xxx FROM DUAL" + }) + void parseSqlStatementWithParametersInsideQuotesAndComments(String sql) { + ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql); + assertThat(parsedSql.getTotalParameterCount()).isEqualTo(1); + assertThat(parsedSql.getParameterNames()).containsExactly("xxx"); } @Test // gh-27716 - public void parseSqlStatementWithSquareBracket() { + void parseSqlStatementWithSquareBracket() { String sql = "SELECT ARRAY[:ext]"; ParsedSql psql = NamedParameterUtils.parseSqlStatement(sql); assertThat(psql.getNamedParameterCount()).isEqualTo(1); assertThat(psql.getParameterNames()).containsExactly("ext"); + + assertThat(expand(psql)).isEqualTo("SELECT ARRAY[$1]"); + } + + @Test // gh-31596 + void paramNameWithNestedSquareBrackets() { + String sql = "insert into GeneratedAlways (id, first_name, last_name) values " + + "(:records[0].id, :records[0].firstName, :records[0].lastName), " + + "(:records[1].id, :records[1].firstName, :records[1].lastName)"; + + ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql); + assertThat(parsedSql.getParameterNames()).containsOnly( + "records[0].id", "records[0].firstName", "records[0].lastName", + "records[1].id", "records[1].firstName", "records[1].lastName"); } @Test // gh-27925 @@ -287,6 +290,14 @@ void namedParamMapReference() { assertThat(psql.getParameterNames()).containsExactly("headers[id]"); } + @Test // gh-31944 / gh-32285 + void parseSqlStatementWithBackticks() { + String sql = "select * from `tb&user` where id = :id"; + ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql); + assertThat(parsedSql.getParameterNames()).containsExactly("id"); + assertThat(expand(parsedSql)).isEqualTo("select * from `tb&user` where id = $1"); + } + @Test void shouldAllowParsingMultipleUseOfParameter() { String sql = "SELECT * FROM person where name = :id or lastname = :id"; diff --git a/spring-test/spring-test.gradle b/spring-test/spring-test.gradle index ed316f9f77ef..f0bc5a7f635f 100644 --- a/spring-test/spring-test.gradle +++ b/spring-test/spring-test.gradle @@ -29,20 +29,22 @@ dependencies { optional("jakarta.xml.bind:jakarta.xml.bind-api") optional("javax.inject:javax.inject") optional("junit:junit") - optional("net.sourceforge.htmlunit:htmlunit") { - exclude group: "commons-logging", module: "commons-logging" - } optional("org.apache.groovy:groovy") optional("org.apache.tomcat.embed:tomcat-embed-core") optional("org.aspectj:aspectjweaver") + optional("org.assertj:assertj-core") optional("org.hamcrest:hamcrest") + optional("org.htmlunit:htmlunit") { + exclude group: "commons-logging", module: "commons-logging" + } optional("org.jetbrains.kotlin:kotlin-reflect") optional("org.jetbrains.kotlin:kotlin-stdlib") optional("org.jetbrains.kotlinx:kotlinx-coroutines-core") optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") optional("org.junit.jupiter:junit-jupiter-api") optional("org.junit.platform:junit-platform-launcher") // for AOT processing - optional("org.seleniumhq.selenium:htmlunit-driver") { + optional("org.mockito:mockito-core") + optional("org.seleniumhq.selenium:htmlunit3-driver") { exclude group: "commons-logging", module: "commons-logging" exclude group: "net.bytebuddy", module: "byte-buddy" } @@ -75,6 +77,7 @@ dependencies { exclude group: "commons-logging", module: "commons-logging" } testImplementation("org.awaitility:awaitility") + testImplementation("org.easymock:easymock") testImplementation("org.hibernate:hibernate-core-jakarta") testImplementation("org.hibernate:hibernate-validator") testImplementation("org.hsqldb:hsqldb") diff --git a/spring-test/src/main/java/org/springframework/mock/http/client/MockClientHttpRequest.java b/spring-test/src/main/java/org/springframework/mock/http/client/MockClientHttpRequest.java index bac710f1bf9f..10736fe4f024 100644 --- a/spring-test/src/main/java/org/springframework/mock/http/client/MockClientHttpRequest.java +++ b/spring-test/src/main/java/org/springframework/mock/http/client/MockClientHttpRequest.java @@ -18,6 +18,8 @@ import java.io.IOException; import java.net.URI; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.springframework.http.HttpMethod; import org.springframework.http.client.ClientHttpRequest; @@ -46,6 +48,9 @@ public class MockClientHttpRequest extends MockHttpOutputMessage implements Clie private boolean executed = false; + @Nullable + Map attributes; + /** * Create a {@code MockClientHttpRequest} with {@link HttpMethod#GET GET} as @@ -115,6 +120,16 @@ public boolean isExecuted() { return this.executed; } + @Override + public Map getAttributes() { + Map attributes = this.attributes; + if (attributes == null) { + attributes = new ConcurrentHashMap<>(); + this.attributes = attributes; + } + return attributes; + } + /** * Set the {@link #isExecuted() executed} flag to {@code true} and return the * configured {@link #setResponse(ClientHttpResponse) response}. diff --git a/spring-test/src/main/java/org/springframework/mock/http/client/reactive/MockClientHttpRequest.java b/spring-test/src/main/java/org/springframework/mock/http/client/reactive/MockClientHttpRequest.java index 3291de25fb74..0f77999f0f78 100644 --- a/spring-test/src/main/java/org/springframework/mock/http/client/reactive/MockClientHttpRequest.java +++ b/spring-test/src/main/java/org/springframework/mock/http/client/reactive/MockClientHttpRequest.java @@ -77,7 +77,7 @@ public MockClientHttpRequest(HttpMethod httpMethod, URI url) { * Configure a custom handler for writing the request body. * *

    The default write handler consumes and caches the request body so it - * may be accessed subsequently, e.g. in test assertions. Use this property + * may be accessed subsequently, for example, in test assertions. Use this property * when the request body is an infinite stream. * @param writeHandler the write handler to use returning {@code Mono} * when the body has been "written" (i.e. consumed). @@ -119,6 +119,10 @@ protected void applyCookies() { .forEach(cookie -> getHeaders().add(HttpHeaders.COOKIE, cookie.toString())); } + @Override + protected void applyAttributes() { + } + @Override public Mono writeWith(Publisher body) { return doCommit(() -> Mono.defer(() -> this.writeHandler.apply(Flux.from(body)))); 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 59f1c395e03b..c7ec34d0882a 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-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,9 +43,17 @@ public class MockCookie extends Cookie { private static final long serialVersionUID = 4312531139502726325L; + private static final String PATH = "Path"; + private static final String DOMAIN = "Domain"; + private static final String COMMENT = "Comment"; + private static final String SECURE = "Secure"; + private static final String HTTP_ONLY = "HttpOnly"; + private static final String PARTITIONED = "Partitioned"; private static final String SAME_SITE = "SameSite"; + private static final String MAX_AGE = "Max-Age"; private static final String EXPIRES = "Expires"; + @Nullable private ZonedDateTime expires; @@ -98,6 +106,29 @@ public String getSameSite() { return getAttribute(SAME_SITE); } + /** + * Set the "Partitioned" attribute for this cookie. + * @since 6.2 + * @see The Partitioned attribute spec + */ + public void setPartitioned(boolean partitioned) { + if (partitioned) { + setAttribute(PARTITIONED, ""); + } + else { + setAttribute(PARTITIONED, null); + } + } + + /** + * Return whether the "Partitioned" attribute is set for this cookie. + * @since 6.2 + * @see The Partitioned attribute spec + */ + public boolean isPartitioned() { + return getAttribute(PARTITIONED) != null; + } + /** * Factory method that parses the value of the supplied "Set-Cookie" header. * @param setCookieHeader the "Set-Cookie" value; never {@code null} or empty @@ -116,10 +147,10 @@ public static MockCookie parse(String setCookieHeader) { MockCookie cookie = new MockCookie(name, value); for (String attribute : attributes) { - if (StringUtils.startsWithIgnoreCase(attribute, "Domain")) { + if (StringUtils.startsWithIgnoreCase(attribute, DOMAIN)) { cookie.setDomain(extractAttributeValue(attribute, setCookieHeader)); } - else if (StringUtils.startsWithIgnoreCase(attribute, "Max-Age")) { + else if (StringUtils.startsWithIgnoreCase(attribute, MAX_AGE)) { cookie.setMaxAge(Integer.parseInt(extractAttributeValue(attribute, setCookieHeader))); } else if (StringUtils.startsWithIgnoreCase(attribute, EXPIRES)) { @@ -131,21 +162,24 @@ else if (StringUtils.startsWithIgnoreCase(attribute, EXPIRES)) { // ignore invalid date formats } } - else if (StringUtils.startsWithIgnoreCase(attribute, "Path")) { + else if (StringUtils.startsWithIgnoreCase(attribute, PATH)) { cookie.setPath(extractAttributeValue(attribute, setCookieHeader)); } - else if (StringUtils.startsWithIgnoreCase(attribute, "Secure")) { + else if (StringUtils.startsWithIgnoreCase(attribute, SECURE)) { cookie.setSecure(true); } - else if (StringUtils.startsWithIgnoreCase(attribute, "HttpOnly")) { + else if (StringUtils.startsWithIgnoreCase(attribute, HTTP_ONLY)) { cookie.setHttpOnly(true); } else if (StringUtils.startsWithIgnoreCase(attribute, SAME_SITE)) { cookie.setSameSite(extractAttributeValue(attribute, setCookieHeader)); } - else if (StringUtils.startsWithIgnoreCase(attribute, "Comment")) { + else if (StringUtils.startsWithIgnoreCase(attribute, COMMENT)) { cookie.setComment(extractAttributeValue(attribute, setCookieHeader)); } + else if (!attribute.isEmpty()) { + cookie.setAttribute(attribute, extractOptionalAttributeValue(attribute, setCookieHeader)); + } } return cookie; } @@ -157,6 +191,11 @@ private static String extractAttributeValue(String attribute, String header) { return nameAndValue[1]; } + private static String extractOptionalAttributeValue(String attribute, String header) { + String[] nameAndValue = attribute.split("="); + return nameAndValue.length == 2 ? nameAndValue[1] : ""; + } + @Override public void setAttribute(String name, @Nullable String value) { if (EXPIRES.equalsIgnoreCase(name)) { @@ -170,14 +209,15 @@ public String toString() { return new ToStringCreator(this) .append("name", getName()) .append("value", getValue()) - .append("Path", getPath()) - .append("Domain", getDomain()) + .append(PATH, getPath()) + .append(DOMAIN, getDomain()) .append("Version", getVersion()) - .append("Comment", getComment()) - .append("Secure", getSecure()) - .append("HttpOnly", isHttpOnly()) + .append(COMMENT, getComment()) + .append(SECURE, getSecure()) + .append(HTTP_ONLY, isHttpOnly()) + .append(PARTITIONED, isPartitioned()) .append(SAME_SITE, getSameSite()) - .append("Max-Age", getMaxAge()) + .append(MAX_AGE, getMaxAge()) .append(EXPIRES, getAttribute(EXPIRES)) .toString(); } diff --git a/spring-test/src/main/java/org/springframework/mock/web/MockFilterRegistration.java b/spring-test/src/main/java/org/springframework/mock/web/MockFilterRegistration.java new file mode 100644 index 000000000000..b73c4bfb4dc3 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/mock/web/MockFilterRegistration.java @@ -0,0 +1,140 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.mock.web; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.FilterRegistration; + +import org.springframework.lang.Nullable; + +/** + * Mock implementation of {@link FilterRegistration}. + * + * @author Rossen Stoyanchev + * @since 6.2 + */ +public class MockFilterRegistration implements FilterRegistration.Dynamic { + + private final String name; + + private final String className; + + private final Map initParameters = new LinkedHashMap<>(); + + private final List servletNames = new ArrayList<>(); + + private final List urlPatterns = new ArrayList<>(); + + private boolean asyncSupported; + + + public MockFilterRegistration(String className) { + this(className, ""); + } + + public MockFilterRegistration(String className, String name) { + this.name = name; + this.className = className; + } + + + @Override + public String getName() { + return this.name; + } + + @Nullable + @Override + public String getClassName() { + return this.className; + } + + @Override + public boolean setInitParameter(String name, String value) { + return (this.initParameters.putIfAbsent(name, value) != null); + } + + @Nullable + @Override + public String getInitParameter(String name) { + return this.initParameters.get(name); + } + + @Override + public Set setInitParameters(Map initParameters) { + Set existingParameterNames = new LinkedHashSet<>(); + for (Map.Entry entry : initParameters.entrySet()) { + if (this.initParameters.get(entry.getKey()) != null) { + existingParameterNames.add(entry.getKey()); + } + } + if (existingParameterNames.isEmpty()) { + this.initParameters.putAll(initParameters); + } + return existingParameterNames; + } + + @Override + public Map getInitParameters() { + return Collections.unmodifiableMap(this.initParameters); + } + + @Override + public void addMappingForServletNames( + EnumSet dispatcherTypes, boolean isMatchAfter, String... servletNames) { + + this.servletNames.addAll(Arrays.asList(servletNames)); + } + + @Override + public Collection getServletNameMappings() { + return Collections.unmodifiableCollection(this.servletNames); + } + + @Override + public void addMappingForUrlPatterns( + EnumSet dispatcherTypes, boolean isMatchAfter, String... urlPatterns) { + + this.urlPatterns.addAll(Arrays.asList(urlPatterns)); + } + + @Override + public Collection getUrlPatternMappings() { + return Collections.unmodifiableCollection(this.urlPatterns); + } + + @Override + public void setAsyncSupported(boolean asyncSupported) { + this.asyncSupported = asyncSupported; + } + + public boolean isAsyncSupported() { + return this.asyncSupported; + } + +} 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 dc7fa427135d..bb1b861fdd64 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 @@ -256,6 +256,9 @@ public class MockHttpServletRequest implements HttpServletRequest { @Nullable private String requestedSessionId; + @Nullable + private String uriTemplate; + @Nullable private String requestURI; @@ -1285,6 +1288,24 @@ public String getRequestedSessionId() { return this.requestedSessionId; } + /** + * Set the original URI template used to prepare the request, if any. + * @param uriTemplate the URI template used to set up the request, if any + * @since 6.2 + */ + public void setUriTemplate(@Nullable String uriTemplate) { + this.uriTemplate = uriTemplate; + } + + /** + * Return the original URI template used to prepare the request, if any. + * @since 6.2 + */ + @Nullable + public String getUriTemplate() { + return this.uriTemplate; + } + public void setRequestURI(@Nullable String requestURI) { this.requestURI = requestURI; } @@ -1440,7 +1461,7 @@ public HttpServletMapping getHttpServletMapping() { } /** - * Best effort to detect a Servlet path mapping, e.g. {@code "/foo/*"}, by + * Best effort to detect a Servlet path mapping, for example, {@code "/foo/*"}, by * checking whether the length of requestURI > contextPath + servletPath. * This helps {@link org.springframework.web.util.ServletRequestPathUtils} * to take into account the Servlet path when parsing the requestURI. 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 19e8753e4fe8..592e216763a3 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 @@ -24,6 +24,7 @@ import java.io.UnsupportedEncodingException; import java.io.Writer; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; @@ -72,6 +73,8 @@ public class MockHttpServletResponse implements HttpServletResponse { private static final TimeZone GMT = TimeZone.getTimeZone("GMT"); + private static final MediaType APPLICATION_PLUS_JSON = new MediaType("application", "*+json"); + //--------------------------------------------------------------------- // ServletResponse properties @@ -348,6 +351,10 @@ public void setContentType(@Nullable String contentType) { if (mediaType.getCharset() != null) { setExplicitCharacterEncoding(mediaType.getCharset().name()); } + else if (mediaType.isCompatibleWith(MediaType.APPLICATION_JSON) || + mediaType.isCompatibleWith(APPLICATION_PLUS_JSON)) { + this.characterEncoding = StandardCharsets.UTF_8.name(); + } } catch (Exception ex) { // Try to get charset value anyway @@ -481,6 +488,9 @@ else if (expires != null) { if (cookie.isHttpOnly()) { buf.append("; HttpOnly"); } + if (cookie.getAttribute("Partitioned") != null) { + buf.append("; Partitioned"); + } if (cookie instanceof MockCookie mockCookie) { if (StringUtils.hasText(mockCookie.getSameSite())) { buf.append("; SameSite=").append(mockCookie.getSameSite()); @@ -623,10 +633,15 @@ public void sendError(int status) throws IOException { @Override public void sendRedirect(String url) throws IOException { + sendRedirect(url, HttpServletResponse.SC_MOVED_TEMPORARILY, true); + } + + // @Override - on Servlet 6.1 + public void sendRedirect(String url, int sc, boolean clearBuffer) throws IOException { Assert.state(!isCommitted(), "Cannot send redirect - response is already committed"); Assert.notNull(url, "Redirect URL must not be null"); setHeader(HttpHeaders.LOCATION, url); - setStatus(HttpServletResponse.SC_MOVED_TEMPORARILY); + setStatus(sc); setCommitted(true); } diff --git a/spring-test/src/main/java/org/springframework/mock/web/MockMultipartHttpServletRequest.java b/spring-test/src/main/java/org/springframework/mock/web/MockMultipartHttpServletRequest.java index d6c1afa84ab5..41411d366cce 100644 --- a/spring-test/src/main/java/org/springframework/mock/web/MockMultipartHttpServletRequest.java +++ b/spring-test/src/main/java/org/springframework/mock/web/MockMultipartHttpServletRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -109,7 +109,7 @@ public List getFiles(String name) { @Override public Map getFileMap() { - return this.multipartFiles.toSingleValueMap(); + return this.multipartFiles.asSingleValueMap(); } @Override diff --git a/spring-test/src/main/java/org/springframework/mock/web/MockServletContext.java b/spring-test/src/main/java/org/springframework/mock/web/MockServletContext.java index ebdc614ffb8f..38e7dae2ba99 100644 --- a/spring-test/src/main/java/org/springframework/mock/web/MockServletContext.java +++ b/spring-test/src/main/java/org/springframework/mock/web/MockServletContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,6 +52,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; @@ -92,7 +93,7 @@ public class MockServletContext implements ServletContext { private static final String TEMP_DIR_SYSTEM_PROPERTY = "java.io.tmpdir"; - private static final Set DEFAULT_SESSION_TRACKING_MODES = new LinkedHashSet<>(4); + private static final Set DEFAULT_SESSION_TRACKING_MODES = CollectionUtils.newLinkedHashSet(3); static { DEFAULT_SESSION_TRACKING_MODES.add(SessionTrackingMode.COOKIE); @@ -144,6 +145,8 @@ public class MockServletContext implements ServletContext { @Nullable private String responseCharacterEncoding; + private final Map filterRegistrations = new LinkedHashMap<>(); + private final Map mimeTypes = new LinkedHashMap<>(); @@ -304,7 +307,7 @@ public Set getResourcePaths(String path) { if (ObjectUtils.isEmpty(fileList)) { return null; } - Set resourcePaths = new LinkedHashSet<>(fileList.length); + Set resourcePaths = CollectionUtils.newLinkedHashSet(fileList.length); for (String fileEntry : fileList) { String resultPath = actualPath + fileEntry; if (resource.createRelative(fileEntry).getFile().isDirectory()) { @@ -603,6 +606,25 @@ public String getResponseCharacterEncoding() { return this.responseCharacterEncoding; } + /** + * Add a {@link FilterRegistration}. + * @since 6.2 + */ + public void addFilterRegistration(FilterRegistration registration) { + this.filterRegistrations.put(registration.getName(), registration); + } + + @Override + @Nullable + public FilterRegistration getFilterRegistration(String filterName) { + return this.filterRegistrations.get(filterName); + } + + @Override + public Map getFilterRegistrations() { + return Collections.unmodifiableMap(this.filterRegistrations); + } + //--------------------------------------------------------------------- // Unsupported Servlet 3.0 registration methods @@ -677,25 +699,6 @@ public T createFilter(Class c) throws ServletException { throw new UnsupportedOperationException(); } - /** - * This method always returns {@code null}. - * @see jakarta.servlet.ServletContext#getFilterRegistration(java.lang.String) - */ - @Override - @Nullable - public FilterRegistration getFilterRegistration(String filterName) { - return null; - } - - /** - * This method always returns an {@linkplain Collections#emptyMap empty map}. - * @see jakarta.servlet.ServletContext#getFilterRegistrations() - */ - @Override - public Map getFilterRegistrations() { - return Collections.emptyMap(); - } - @Override public void addListener(Class listenerClass) { throw new UnsupportedOperationException(); diff --git a/spring-test/src/main/java/org/springframework/test/annotation/DirtiesContext.java b/spring-test/src/main/java/org/springframework/test/annotation/DirtiesContext.java index dff1ce6a26cc..6e602f53380a 100644 --- a/spring-test/src/main/java/org/springframework/test/annotation/DirtiesContext.java +++ b/spring-test/src/main/java/org/springframework/test/annotation/DirtiesContext.java @@ -35,8 +35,8 @@ * will be supplied a new context. * *

    {@code @DirtiesContext} may be used as a class-level and method-level - * annotation within the same class or class hierarchy. In such scenarios, the - * {@code ApplicationContext} will be marked as dirty before or + * annotation within the same test class or test class hierarchy. In such scenarios, + * the {@code ApplicationContext} will be marked as dirty before or * after any such annotated method as well as before or after the current test * class, depending on the configured {@link #methodMode} and {@link #classMode}. * When {@code @DirtiesContext} is declared at both the class level and the diff --git a/spring-test/src/main/java/org/springframework/test/annotation/IfProfileValue.java b/spring-test/src/main/java/org/springframework/test/annotation/IfProfileValue.java index 6eee643cc9fc..9af1ec7492e2 100644 --- a/spring-test/src/main/java/org/springframework/test/annotation/IfProfileValue.java +++ b/spring-test/src/main/java/org/springframework/test/annotation/IfProfileValue.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2024 the original author 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,8 @@ import java.lang.annotation.Target; /** - * Test annotation for use with JUnit 4 to indicate whether a test is enabled or - * disabled for a specific testing profile. + * Test annotation for use with JUnit 4 to indicate whether the annotated test + * class or test method is enabled or disabled for a specific testing profile. * *

    In the context of this annotation, the term profile refers to * a Java system property by default; however, the semantics can be changed diff --git a/spring-test/src/main/java/org/springframework/test/annotation/ProfileValueSourceConfiguration.java b/spring-test/src/main/java/org/springframework/test/annotation/ProfileValueSourceConfiguration.java index 4197550f26f9..37af23cb3d2f 100644 --- a/spring-test/src/main/java/org/springframework/test/annotation/ProfileValueSourceConfiguration.java +++ b/spring-test/src/main/java/org/springframework/test/annotation/ProfileValueSourceConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2024 the original author 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,8 @@ import java.lang.annotation.Target; /** - * {@code ProfileValueSourceConfiguration} is a class-level annotation for use - * with JUnit 4 which is used to specify what type of {@link ProfileValueSource} + * {@code ProfileValueSourceConfiguration} is an annotation that can be applied + * to a JUnit 4 based test class to specify what type of {@link ProfileValueSource} * to use when retrieving profile values configured via * {@link IfProfileValue @IfProfileValue}. * diff --git a/spring-test/src/main/java/org/springframework/test/annotation/SystemProfileValueSource.java b/spring-test/src/main/java/org/springframework/test/annotation/SystemProfileValueSource.java index db72a2f69e42..4a0f627664a7 100644 --- a/spring-test/src/main/java/org/springframework/test/annotation/SystemProfileValueSource.java +++ b/spring-test/src/main/java/org/springframework/test/annotation/SystemProfileValueSource.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-test/src/main/java/org/springframework/test/context/ActiveProfiles.java b/spring-test/src/main/java/org/springframework/test/context/ActiveProfiles.java index dc5711b7fece..97d5959319a1 100644 --- a/spring-test/src/main/java/org/springframework/test/context/ActiveProfiles.java +++ b/spring-test/src/main/java/org/springframework/test/context/ActiveProfiles.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,10 +26,10 @@ import org.springframework.core.annotation.AliasFor; /** - * {@code ActiveProfiles} is a class-level annotation that is used to declare - * which active bean definition profiles should be used when loading - * an {@link org.springframework.context.ApplicationContext ApplicationContext} - * for test classes. + * {@code ActiveProfiles} is an annotation that can be applied to a test class + * to declare which active bean definition profiles should be used when + * loading an {@link org.springframework.context.ApplicationContext ApplicationContext} + * for integration tests. * *

    This annotation may be used as a meta-annotation to create custom * composed annotations. diff --git a/spring-test/src/main/java/org/springframework/test/context/BootstrapWith.java b/spring-test/src/main/java/org/springframework/test/context/BootstrapWith.java index 1a00c818d455..17b5c61ace0a 100644 --- a/spring-test/src/main/java/org/springframework/test/context/BootstrapWith.java +++ b/spring-test/src/main/java/org/springframework/test/context/BootstrapWith.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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,11 +24,12 @@ import java.lang.annotation.Target; /** - * {@code @BootstrapWith} defines class-level metadata that is used to determine - * how to bootstrap the Spring TestContext Framework. + * {@code @BootstrapWith} is an annotation that can be applied to a test class + * to define metadata that is used to determine how to bootstrap the + * Spring TestContext Framework. * *

    This annotation may also be used as a meta-annotation to create - * custom composed annotations. As of Spring Framework 5.1, a locally + * custom composed annotations. Note, however, that a locally * declared {@code @BootstrapWith} annotation (i.e., one that is directly * present on the current test class) will override any meta-present * declarations of {@code @BootstrapWith}. diff --git a/spring-test/src/main/java/org/springframework/test/context/ContextConfiguration.java b/spring-test/src/main/java/org/springframework/test/context/ContextConfiguration.java index e4680df48af7..90bb738b7774 100644 --- a/spring-test/src/main/java/org/springframework/test/context/ContextConfiguration.java +++ b/spring-test/src/main/java/org/springframework/test/context/ContextConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +28,10 @@ import org.springframework.core.annotation.AliasFor; /** - * {@code @ContextConfiguration} defines class-level metadata that is used to determine - * how to load and configure an {@link org.springframework.context.ApplicationContext - * ApplicationContext} for integration tests. + * {@code @ContextConfiguration} is an annotation that can be applied to a test + * class to define metadata that is used to determine how to load and configure + * an {@link org.springframework.context.ApplicationContext ApplicationContext} + * for integration tests. * *

    Supported Resource Types

    * diff --git a/spring-test/src/main/java/org/springframework/test/context/ContextCustomizerFactories.java b/spring-test/src/main/java/org/springframework/test/context/ContextCustomizerFactories.java index 25ebff330f74..2ed89c67abae 100644 --- a/spring-test/src/main/java/org/springframework/test/context/ContextCustomizerFactories.java +++ b/spring-test/src/main/java/org/springframework/test/context/ContextCustomizerFactories.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +26,9 @@ import org.springframework.core.annotation.AliasFor; /** - * {@code @ContextCustomizerFactories} defines class-level metadata for configuring - * which {@link ContextCustomizerFactory} implementations should be registered with - * the Spring TestContext Framework. + * {@code @ContextCustomizerFactories} is an annotation that can be applied to a + * test class to configure which {@link ContextCustomizerFactory} implementations + * should be registered with the Spring TestContext Framework. * *

    {@code @ContextCustomizerFactories} is used to register factories for a * particular test class, its subclasses, and its nested classes. If you wish to diff --git a/spring-test/src/main/java/org/springframework/test/context/ContextHierarchy.java b/spring-test/src/main/java/org/springframework/test/context/ContextHierarchy.java index 9c298106f31c..0785c965f8c8 100644 --- a/spring-test/src/main/java/org/springframework/test/context/ContextHierarchy.java +++ b/spring-test/src/main/java/org/springframework/test/context/ContextHierarchy.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,8 @@ import java.lang.annotation.Target; /** - * {@code @ContextHierarchy} is a class-level annotation that is used to define - * a hierarchy of {@link org.springframework.context.ApplicationContext + * {@code @ContextHierarchy} is an annotation that can be applied to a test class + * to define a hierarchy of {@link org.springframework.context.ApplicationContext * ApplicationContexts} for integration tests. * *

    Examples

    diff --git a/spring-test/src/main/java/org/springframework/test/context/ContextLoader.java b/spring-test/src/main/java/org/springframework/test/context/ContextLoader.java index 3ed1903632df..154dc769fc5b 100644 --- a/spring-test/src/main/java/org/springframework/test/context/ContextLoader.java +++ b/spring-test/src/main/java/org/springframework/test/context/ContextLoader.java @@ -77,7 +77,7 @@ public interface ContextLoader { * must register a JVM shutdown hook for itself. Unless the * context gets closed early, all context instances will be automatically * closed on JVM shutdown. This allows for freeing external resources held by - * beans within the context, e.g. temporary files. + * beans within the context, for example, temporary files. * @param locations the resource locations to use to load the application context * @return a new application context * @throws Exception if context loading failed diff --git a/spring-test/src/main/java/org/springframework/test/context/DynamicPropertyRegistrar.java b/spring-test/src/main/java/org/springframework/test/context/DynamicPropertyRegistrar.java new file mode 100644 index 000000000000..cbdc84c26a93 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/DynamicPropertyRegistrar.java @@ -0,0 +1,90 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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; + +/** + * Registrar that is used to add properties with dynamically resolved values to + * the {@code Environment} via a {@link DynamicPropertyRegistry}. + * + *

    Any bean in a test's {@code ApplicationContext} that implements the + * {@code DynamicPropertyRegistrar} interface will be automatically detected and + * eagerly initialized before the singleton pre-instantiation phase, and the + * {@link #accept} methods of such beans will be invoked with a + * {@code DynamicPropertyRegistry} that performs the actual dynamic property + * registration on behalf of the registrar. + * + *

    This is an alternative to implementing + * {@link DynamicPropertySource @DynamicPropertySource} methods in integration + * test classes and supports additional use cases that are not possible with a + * {@code @DynamicPropertySource} method. For example, since a + * {@code DynamicPropertyRegistrar} is itself a bean in the {@code ApplicationContext}, + * it can interact with other beans in the context and register dynamic properties + * that are sourced from those beans. Note, however, that any interaction with + * other beans results in eager initialization of those other beans and their + * dependencies. + * + *

    Precedence

    + * + *

    Dynamic properties have higher precedence than those loaded from + * {@link TestPropertySource @TestPropertySource}, the operating system's + * environment, Java system properties, or property sources added by the + * application declaratively by using + * {@link org.springframework.context.annotation.PropertySource @PropertySource} + * or programmatically. Thus, dynamic properties can be used to selectively + * override properties loaded via {@code @TestPropertySource}, system property + * sources, and application property sources. + * + *

    Example

    + * + *

    The following example demonstrates how to implement a + * {@code DynamicPropertyRegistrar} as a lambda expression that registers a + * dynamic property for the {@code ApiServer} bean. Other beans in the + * {@code ApplicationContext} can access the {@code api.url} property which is + * dynamically retrieved from the {@code ApiServer} bean — for example, + * via {@code @Value("${api.url}")}. + * + *

    + * @Configuration
    + * class TestConfig {
    + *
    + *     @Bean
    + *     ApiServer apiServer() {
    + *         return new ApiServer();
    + *     }
    + *
    + *     @Bean
    + *     DynamicPropertyRegistrar apiPropertiesRegistrar(ApiServer apiServer) {
    + *         return registry -> registry.add("api.url", apiServer::getUrl);
    + *     }
    + *
    + * }
    + * + * @author Sam Brannen + * @since 6.2 + * @see DynamicPropertySource + * @see DynamicPropertyRegistry + * @see org.springframework.beans.factory.config.ConfigurableListableBeanFactory#preInstantiateSingletons() + */ +@FunctionalInterface +public interface DynamicPropertyRegistrar { + + /** + * Register dynamic properties in the supplied registry. + */ + void accept(DynamicPropertyRegistry registry); + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/DynamicPropertyRegistry.java b/spring-test/src/main/java/org/springframework/test/context/DynamicPropertyRegistry.java index 2f521c4925d0..2aa2984949cc 100644 --- a/spring-test/src/main/java/org/springframework/test/context/DynamicPropertyRegistry.java +++ b/spring-test/src/main/java/org/springframework/test/context/DynamicPropertyRegistry.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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,14 +19,26 @@ import java.util.function.Supplier; /** - * Registry used with {@link DynamicPropertySource @DynamicPropertySource} - * methods so that they can add properties to the {@code Environment} that have - * dynamically resolved values. + * Registry that is used to add properties with dynamically resolved values to + * the {@code Environment}. + * + *

    A {@code DynamicPropertyRegistry} is supplied as an argument to static + * {@link DynamicPropertySource @DynamicPropertySource} methods in integration + * test classes. + * + *

    As of Spring Framework 6.2, a {@code DynamicPropertyRegistry} is also + * supplied to {@link DynamicPropertyRegistrar} beans in the test's + * {@code ApplicationContext}, making it possible to register dynamic properties + * based on beans in the context. For example, a {@code @Bean} method can return + * a {@code DynamicPropertyRegistrar} that registers a property whose value is + * dynamically sourced from another bean in the context. See the documentation + * for {@code DynamicPropertyRegistrar} for an example. * * @author Phillip Webb * @author Sam Brannen * @since 5.2.5 * @see DynamicPropertySource + * @see DynamicPropertyRegistrar */ public interface DynamicPropertyRegistry { diff --git a/spring-test/src/main/java/org/springframework/test/context/DynamicPropertySource.java b/spring-test/src/main/java/org/springframework/test/context/DynamicPropertySource.java index 57732968e75e..919569d025dc 100644 --- a/spring-test/src/main/java/org/springframework/test/context/DynamicPropertySource.java +++ b/spring-test/src/main/java/org/springframework/test/context/DynamicPropertySource.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,27 +23,35 @@ import java.lang.annotation.Target; /** - * Method-level annotation for integration tests that need to add properties with - * dynamic values to the {@code Environment}'s set of {@code PropertySources}. + * {@code @DynamicPropertySource} is an annotation that can be applied to static + * methods in integration test classes in order to add properties with dynamic + * values to the {@code Environment}'s set of {@code PropertySources}. + * + *

    Alternatively, dynamic properties can be added to the {@code Environment} + * by special beans in the test's {@code ApplicationContext}. See + * {@link DynamicPropertyRegistrar} for details. * *

    This annotation and its supporting infrastructure were originally designed * to allow properties from * Testcontainers based tests to be - * exposed easily to Spring integration tests. However, this feature may also be - * used with any form of external resource whose lifecycle is maintained outside - * the test's {@code ApplicationContext}. + * exposed easily to Spring integration tests. However, this feature may be used + * with any form of external resource whose lifecycle is managed outside the + * test's {@code ApplicationContext}. * - *

    Methods annotated with {@code @DynamicPropertySource} must be {@code static} - * and must have a single {@link DynamicPropertyRegistry} argument which is used + *

    {@code @DynamicPropertySource} methods use a {@link DynamicPropertyRegistry} * to add name-value pairs to the {@code Environment}'s set of * {@code PropertySources}. Values are dynamic and provided via a - * {@link java.util.function.Supplier} which is only invoked when the property - * is resolved. Typically, method references are used to supply values, as in the + * {@link java.util.function.Supplier} which is only invoked when the property is + * resolved. Typically, method references are used to supply values, as in the * example below. * - *

    As of Spring Framework 5.3.2, dynamic properties from methods annotated with - * {@code @DynamicPropertySource} will be inherited from enclosing test - * classes, analogous to inheritance from superclasses and interfaces. See + *

    Methods in integration test classes that are annotated with + * {@code @DynamicPropertySource} must be {@code static} and must accept a single + * {@code DynamicPropertyRegistry} argument. + * + *

    Dynamic properties from methods annotated with {@code @DynamicPropertySource} + * will be inherited from enclosing test classes, analogous to inheritance + * from superclasses and interfaces. See * {@link NestedTestConfiguration @NestedTestConfiguration} for details. * *

    NOTE: if you use {@code @DynamicPropertySource} in a base @@ -54,6 +62,7 @@ * correct dynamic properties. * *

    Precedence

    + * *

    Dynamic properties have higher precedence than those loaded from * {@link TestPropertySource @TestPropertySource}, the operating system's * environment, Java system properties, or property sources added by the @@ -63,7 +72,13 @@ * override properties loaded via {@code @TestPropertySource}, system property * sources, and application property sources. * - *

    Example

    + *

    Examples

    + * + *

    The following example demonstrates how to use {@code @DynamicPropertySource} + * in an integration test class. Beans in the {@code ApplicationContext} can + * access the {@code redis.host} and {@code redis.port} properties which are + * dynamically retrieved from the Redis container. + * *

      * @SpringJUnitConfig(...)
      * @Testcontainers
    @@ -80,13 +95,13 @@
      *         registry.add("redis.host", redis::getHost);
      *         registry.add("redis.port", redis::getFirstMappedPort);
      *     }
    - *
      * }
    * * @author Phillip Webb * @author Sam Brannen * @since 5.2.5 * @see DynamicPropertyRegistry + * @see DynamicPropertyRegistrar * @see ContextConfiguration * @see TestPropertySource * @see org.springframework.core.env.PropertySource diff --git a/spring-test/src/main/java/org/springframework/test/context/NestedTestConfiguration.java b/spring-test/src/main/java/org/springframework/test/context/NestedTestConfiguration.java index c348ef157665..9b716cc7f4ef 100644 --- a/spring-test/src/main/java/org/springframework/test/context/NestedTestConfiguration.java +++ b/spring-test/src/main/java/org/springframework/test/context/NestedTestConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,9 +30,9 @@ import org.springframework.lang.Nullable; /** - * {@code @NestedTestConfiguration} is a type-level annotation that is used to - * configure how Spring test configuration annotations are processed within - * enclosing class hierarchies (i.e., for inner test classes). + * {@code @NestedTestConfiguration} is an annotation that can be applied to a test + * class to configure how Spring test configuration annotations are processed + * within enclosing class hierarchies (i.e., for inner test classes). * *

    If {@code @NestedTestConfiguration} is not present or * meta-present on a test class, in its supertype hierarchy, or in its @@ -59,10 +59,16 @@ *

    This annotation may be used as a meta-annotation to create custom * composed annotations. * - *

    As of Spring Framework 5.3, the use of this annotation typically only makes - * sense in conjunction with {@link org.junit.jupiter.api.Nested @Nested} test - * classes in JUnit Jupiter; however, there may be other testing frameworks with - * support for nested test classes that could also make use of this annotation. + *

    The use of this annotation typically only makes sense in conjunction with + * {@link org.junit.jupiter.api.Nested @Nested} test classes in JUnit Jupiter; + * however, there may be other testing frameworks with support for nested test + * classes that could also make use of this annotation. + * + *

    If you are developing a component that integrates with the Spring TestContext + * Framework and needs to support annotation inheritance within enclosing class + * hierarchies, you must use the annotation search utilities provided in + * {@link TestContextAnnotationUtils} in order to honor + * {@code @NestedTestConfiguration} semantics. * *

    Supported Annotations

    *

    The Spring TestContext Framework honors {@code @NestedTestConfiguration} @@ -76,6 +82,9 @@ *

  • {@link ActiveProfiles @ActiveProfiles}
  • *
  • {@link TestPropertySource @TestPropertySource}
  • *
  • {@link DynamicPropertySource @DynamicPropertySource}
  • + *
  • {@link org.springframework.test.context.bean.override.convention.TestBean @TestBean}
  • + *
  • {@link org.springframework.test.context.bean.override.mockito.MockitoBean @MockitoBean}
  • + *
  • {@link org.springframework.test.context.bean.override.mockito.MockitoSpyBean @MockitoSpyBean}
  • *
  • {@link org.springframework.test.annotation.DirtiesContext @DirtiesContext}
  • *
  • {@link org.springframework.transaction.annotation.Transactional @Transactional}
  • *
  • {@link org.springframework.test.annotation.Rollback @Rollback}
  • @@ -90,6 +99,7 @@ * @since 5.3 * @see EnclosingConfiguration#INHERIT * @see EnclosingConfiguration#OVERRIDE + * @see TestContextAnnotationUtils */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) diff --git a/spring-test/src/main/java/org/springframework/test/context/TestConstructor.java b/spring-test/src/main/java/org/springframework/test/context/TestConstructor.java index 8997f455b611..3285ca6f5acc 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestConstructor.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestConstructor.java @@ -30,9 +30,9 @@ import org.springframework.lang.Nullable; /** - * {@code @TestConstructor} is a type-level annotation that is used to configure - * how the parameters of a test class constructor are autowired from components - * in the test's {@link org.springframework.context.ApplicationContext + * {@code @TestConstructor} is an annotation that can be applied to a test class + * to configure how the parameters of a test class constructor are autowired from + * components in the test's {@link org.springframework.context.ApplicationContext * ApplicationContext}. * *

    If {@code @TestConstructor} is not present or meta-present @@ -47,8 +47,8 @@ *

    This annotation may be used as a meta-annotation to create custom * composed annotations. * - *

    As of Spring Framework 5.2, this annotation is only supported in conjunction - * with the {@link org.springframework.test.context.junit.jupiter.SpringExtension + *

    This annotation is only supported in conjunction with the + * {@link org.springframework.test.context.junit.jupiter.SpringExtension * SpringExtension} for use with JUnit Jupiter. Note that the {@code SpringExtension} is * often automatically registered for you — for example, when using annotations such as * {@link org.springframework.test.context.junit.jupiter.SpringJUnitConfig @SpringJUnitConfig} and diff --git a/spring-test/src/main/java/org/springframework/test/context/TestContext.java b/spring-test/src/main/java/org/springframework/test/context/TestContext.java index cce8366ece80..3e6d5109c13d 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestContext.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,13 +30,13 @@ * {@code TestContext} encapsulates the context in which a test is executed, * agnostic of the actual testing framework in use. * - *

    As of Spring Framework 5.0, concrete implementations are highly encouraged - * to implement a copy constructor in order to allow the immutable state - * and attributes of a {@code TestContext} to be used as a template for additional - * contexts created for parallel test execution. The copy constructor must accept a - * single argument of the type of the concrete implementation. Any implementation - * that does not provide a copy constructor will likely fail in an environment - * that executes tests concurrently. + *

    Concrete implementations are highly encouraged to implement a copy + * constructor in order to allow the immutable state and attributes of a + * {@code TestContext} to be used as a template for additional contexts created + * for parallel test execution. The copy constructor must accept a single argument + * of the type of the concrete implementation. Any implementation that does not + * provide a copy constructor will likely fail in an environment that executes + * tests concurrently. * *

    As of Spring Framework 6.1, concrete implementations are highly encouraged to * override {@link #setMethodInvoker(MethodInvoker)} and {@link #getMethodInvoker()}. diff --git a/spring-test/src/main/java/org/springframework/test/context/TestContextAnnotationUtils.java b/spring-test/src/main/java/org/springframework/test/context/TestContextAnnotationUtils.java index be01566361c7..6053c115726e 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestContextAnnotationUtils.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestContextAnnotationUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -47,7 +47,8 @@ * and {@link AnnotatedElementUtils}, while transparently honoring * {@link NestedTestConfiguration @NestedTestConfiguration} semantics. * - *

    Mainly for internal use within the Spring TestContext Framework. + *

    Mainly for internal use within the Spring TestContext Framework + * but also supported for third-party integrations with the TestContext framework. * *

    Whereas {@code AnnotationUtils} and {@code AnnotatedElementUtils} provide * utilities for getting or finding annotations, @@ -70,6 +71,7 @@ * @see AnnotationUtils * @see AnnotatedElementUtils * @see AnnotationDescriptor + * @see NestedTestConfiguration */ public abstract class TestContextAnnotationUtils { diff --git a/spring-test/src/main/java/org/springframework/test/context/TestContextManager.java b/spring-test/src/main/java/org/springframework/test/context/TestContextManager.java index 6754b2210339..8664d8d61c67 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestContextManager.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestContextManager.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author 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,7 +20,6 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; -import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -29,6 +28,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; import org.springframework.util.ReflectionUtils; /** @@ -92,7 +92,7 @@ public class TestContextManager { private static final Log logger = LogFactory.getLog(TestContextManager.class); - private static final Set> skippedExceptionTypes = new LinkedHashSet<>(4); + private static final Set> skippedExceptionTypes = CollectionUtils.newLinkedHashSet(3); static { // JUnit Jupiter @@ -176,7 +176,7 @@ public void registerTestExecutionListeners(TestExecutionListener... testExecutio /** * Get the current {@link TestExecutionListener TestExecutionListeners} * registered for this {@code TestContextManager}. - *

    Allows for modifications, e.g. adding a listener to the beginning of the list. + *

    Allows for modifications, for example, adding a listener to the beginning of the list. * However, make sure to keep the list stable while actually executing tests. */ public final List getTestExecutionListeners() { @@ -389,8 +389,8 @@ public void beforeTestExecution(Object testInstance, Method testMethod) throws E * have executed, the first caught exception will be rethrown with any * subsequent exceptions {@linkplain Throwable#addSuppressed suppressed} in * the first exception. - *

    Note that registered listeners will be executed in the opposite - * order in which they were registered. + *

    Note that listeners will be executed in the opposite order in which they + * were registered. * @param testInstance the current test instance * @param testMethod the test method which has just been executed on the * test instance @@ -459,7 +459,8 @@ public void afterTestExecution(Object testInstance, Method testMethod, @Nullable * have executed, the first caught exception will be rethrown with any * subsequent exceptions {@linkplain Throwable#addSuppressed suppressed} in * the first exception. - *

    Note that registered listeners will be executed in the opposite + *

    Note that listeners will be executed in the opposite order in which they + * were registered. * @param testInstance the current test instance * @param testMethod the test method which has just been executed on the * test instance @@ -517,7 +518,8 @@ public void afterTestMethod(Object testInstance, Method testMethod, @Nullable Th * have executed, the first caught exception will be rethrown with any * subsequent exceptions {@linkplain Throwable#addSuppressed suppressed} in * the first exception. - *

    Note that registered listeners will be executed in the opposite + *

    Note that listeners will be executed in the opposite order in which they + * were registered. * @throws Exception if a registered TestExecutionListener throws an exception * @since 3.0 * @see #getTestExecutionListeners() diff --git a/spring-test/src/main/java/org/springframework/test/context/TestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/TestExecutionListener.java index 57fcb43937da..ad8b6ba18086 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestExecutionListener.java @@ -66,18 +66,24 @@ * DirtiesContextBeforeModesTestExecutionListener} *

  • {@link org.springframework.test.context.event.ApplicationEventsTestExecutionListener * ApplicationEventsTestExecutionListener}
  • + *
  • {@link org.springframework.test.context.bean.override.BeanOverrideTestExecutionListener + * BeanOverrideTestExecutionListener}
  • *
  • {@link org.springframework.test.context.support.DependencyInjectionTestExecutionListener * DependencyInjectionTestExecutionListener}
  • *
  • {@link org.springframework.test.context.observation.MicrometerObservationRegistryTestExecutionListener * MicrometerObservationRegistryTestExecutionListener}
  • *
  • {@link org.springframework.test.context.support.DirtiesContextTestExecutionListener * DirtiesContextTestExecutionListener}
  • + *
  • {@link org.springframework.test.context.support.CommonCachesTestExecutionListener + * CommonCachesTestExecutionListener}
  • *
  • {@link org.springframework.test.context.transaction.TransactionalTestExecutionListener * TransactionalTestExecutionListener}
  • *
  • {@link org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener * SqlScriptsTestExecutionListener}
  • *
  • {@link org.springframework.test.context.event.EventPublishingTestExecutionListener * EventPublishingTestExecutionListener}
  • + *
  • {@link org.springframework.test.context.bean.override.mockito.MockitoResetTestExecutionListener + * MockitoResetTestExecutionListener}
  • * * * @author Sam Brannen diff --git a/spring-test/src/main/java/org/springframework/test/context/TestExecutionListeners.java b/spring-test/src/main/java/org/springframework/test/context/TestExecutionListeners.java index c4c26de1b7ae..a06ea72d1b78 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestExecutionListeners.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestExecutionListeners.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +26,9 @@ import org.springframework.core.annotation.AliasFor; /** - * {@code TestExecutionListeners} defines class-level metadata for configuring - * which {@link TestExecutionListener TestExecutionListeners} should be - * registered with a {@link TestContextManager}. + * {@code @TestExecutionListeners} is an annotation that can be applied to a test + * class to configure which {@link TestExecutionListener TestExecutionListeners} + * should be registered with a {@link TestContextManager}. * *

    {@code @TestExecutionListeners} is used to register listeners for a * particular test class, its subclasses, and its nested classes. If you wish to @@ -82,11 +82,14 @@ * @see org.springframework.test.context.web.ServletTestExecutionListener * @see org.springframework.test.context.support.DirtiesContextBeforeModesTestExecutionListener * @see org.springframework.test.context.event.ApplicationEventsTestExecutionListener + * @see org.springframework.test.context.bean.override.BeanOverrideTestExecutionListener * @see org.springframework.test.context.support.DependencyInjectionTestExecutionListener * @see org.springframework.test.context.support.DirtiesContextTestExecutionListener + * @see org.springframework.test.context.support.CommonCachesTestExecutionListener * @see org.springframework.test.context.transaction.TransactionalTestExecutionListener * @see org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener * @see org.springframework.test.context.event.EventPublishingTestExecutionListener + * @see org.springframework.test.context.bean.override.mockito.MockitoResetTestExecutionListener */ @AliasFor("value") Class[] listeners() default {}; diff --git a/spring-test/src/main/java/org/springframework/test/context/TestPropertySource.java b/spring-test/src/main/java/org/springframework/test/context/TestPropertySource.java index 2eadd60be167..f53079a8b7b3 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestPropertySource.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestPropertySource.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 +28,8 @@ import org.springframework.core.io.support.PropertySourceFactory; /** - * {@code @TestPropertySource} is a class-level annotation that is used to - * configure the {@link #locations} of properties files and inlined + * {@code @TestPropertySource} is an annotation that can be applied to a test + * class to configure the {@link #locations} of properties files and inlined * {@link #properties} to be added to the {@code Environment}'s set of * {@code PropertySources} for an * {@link org.springframework.context.ApplicationContext ApplicationContext} @@ -40,7 +40,7 @@ * operating system's environment or Java system properties as well as property * sources added by the application declaratively via * {@link org.springframework.context.annotation.PropertySource @PropertySource} - * or programmatically (e.g., via an + * or programmatically (for example, via an * {@link org.springframework.context.ApplicationContextInitializer ApplicationContextInitializer} * or some other means). Thus, test property sources can be used to selectively * override properties defined in system and application property sources. @@ -128,7 +128,7 @@ * test class is defined. A path starting with a slash will be treated as an * absolute classpath resource, for example: * {@code "/org/example/test.xml"}. A path which references a - * URL (e.g., a path prefixed with + * URL (for example, a path prefixed with * {@link org.springframework.util.ResourceUtils#CLASSPATH_URL_PREFIX classpath:}, * {@link org.springframework.util.ResourceUtils#FILE_URL_PREFIX file:}, * {@code http:}, etc.) will be loaded using the specified resource protocol. diff --git a/spring-test/src/main/java/org/springframework/test/context/aot/DisabledInAotMode.java b/spring-test/src/main/java/org/springframework/test/context/aot/DisabledInAotMode.java index 2a322d2e4569..ad6f16969cb8 100644 --- a/spring-test/src/main/java/org/springframework/test/context/aot/DisabledInAotMode.java +++ b/spring-test/src/main/java/org/springframework/test/context/aot/DisabledInAotMode.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +25,7 @@ import org.junit.jupiter.api.extension.ExtendWith; /** - * {@code @DisabledInAotMode} signals that an annotated test class is disabled + * {@code @DisabledInAotMode} signals that the annotated test class is disabled * in Spring AOT (ahead-of-time) mode, which means that the {@code ApplicationContext} * for the test class will not be processed for AOT optimizations at build time. * @@ -59,4 +59,19 @@ @Documented @ExtendWith(DisabledInAotModeCondition.class) public @interface DisabledInAotMode { + + /** + * Custom reason to document why the test class or test method is disabled in + * AOT mode. + *

    If a custom reason is not supplied, the default reason will be used: + * {@code "Disabled in Spring AOT mode"}. + *

    If a custom reason is supplied, it will be combined with the default + * reason. For example, + * {@code @DisabledInAotMode("@ContextHierarchy is not supported")} will result + * in a combined reason like the following: + * {@code "Disabled in Spring AOT mode ==> @ContextHierarchy is not supported"}. + * @since 6.2 + */ + String value() default ""; + } diff --git a/spring-test/src/main/java/org/springframework/test/context/aot/DisabledInAotModeCondition.java b/spring-test/src/main/java/org/springframework/test/context/aot/DisabledInAotModeCondition.java index e1e2d0d48d0b..d0586931e3a7 100644 --- a/spring-test/src/main/java/org/springframework/test/context/aot/DisabledInAotModeCondition.java +++ b/spring-test/src/main/java/org/springframework/test/context/aot/DisabledInAotModeCondition.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,16 +16,22 @@ package org.springframework.test.context.aot; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.util.Optional; + import org.junit.jupiter.api.extension.ConditionEvaluationResult; import org.junit.jupiter.api.extension.ExecutionCondition; import org.junit.jupiter.api.extension.ExtensionContext; import org.springframework.aot.AotDetector; +import org.springframework.core.annotation.AnnotatedElementUtils; /** - * {@link ExecutionCondition} implementation for {@link DisabledInAotMode}. + * {@link ExecutionCondition} implementation for {@link DisabledInAotMode @DisabledInAotMode}. * * @author Stephane Nicoll + * @author Sam Brannen * @since 6.1.2 */ class DisabledInAotModeCondition implements ExecutionCondition { @@ -34,9 +40,18 @@ class DisabledInAotModeCondition implements ExecutionCondition { public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { boolean aotEnabled = AotDetector.useGeneratedArtifacts(); if (aotEnabled) { - return ConditionEvaluationResult.disabled("Disabled in Spring AOT mode"); + AnnotatedElement element = context.getElement().orElseThrow(() -> new IllegalStateException("No AnnotatedElement")); + String customReason = findMergedAnnotation(element, DisabledInAotMode.class) + .map(DisabledInAotMode::value).orElse(null); + return ConditionEvaluationResult.disabled("Disabled in Spring AOT mode", customReason); } - return ConditionEvaluationResult.enabled("Spring AOT mode disabled"); + return ConditionEvaluationResult.enabled("Spring AOT mode is not enabled"); + } + + private static Optional findMergedAnnotation( + AnnotatedElement element, Class annotationType) { + + return Optional.ofNullable(AnnotatedElementUtils.findMergedAnnotation(element, annotationType)); } } diff --git a/spring-test/src/main/java/org/springframework/test/context/aot/TestContextAotGenerator.java b/spring-test/src/main/java/org/springframework/test/context/aot/TestContextAotGenerator.java index 51c4ded7cc17..7d81e97629d1 100644 --- a/spring-test/src/main/java/org/springframework/test/context/aot/TestContextAotGenerator.java +++ b/spring-test/src/main/java/org/springframework/test/context/aot/TestContextAotGenerator.java @@ -352,6 +352,7 @@ private GenericApplicationContext loadContextForAotProcessing( } catch (Exception ex) { Throwable cause = (ex instanceof ContextLoadException cle ? cle.getCause() : ex); + Assert.state(cause != null, "Cause must not be null"); throw new TestContextAotException( "Failed to load ApplicationContext for AOT processing for test class [%s]" .formatted(testClass.getName()), cause); diff --git a/spring-test/src/main/java/org/springframework/test/context/aot/TestRuntimeHintsRegistrar.java b/spring-test/src/main/java/org/springframework/test/context/aot/TestRuntimeHintsRegistrar.java index 22a87c769b7b..ac08bd0fbc7f 100644 --- a/spring-test/src/main/java/org/springframework/test/context/aot/TestRuntimeHintsRegistrar.java +++ b/spring-test/src/main/java/org/springframework/test/context/aot/TestRuntimeHintsRegistrar.java @@ -37,7 +37,7 @@ *

    As an alternative to implementing and registering a {@code TestRuntimeHintsRegistrar}, * you may choose to annotate a test class with * {@link org.springframework.aot.hint.annotation.Reflective @Reflective}, - * {@link org.springframework.aot.hint.annotation.RegisterReflectionForBinding @RegisterReflectionForBinding}, + * {@link org.springframework.aot.hint.annotation.RegisterReflection @RegisterReflection}, * or {@link org.springframework.context.annotation.ImportRuntimeHints @ImportRuntimeHints}. * * @author Sam Brannen diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverride.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverride.java new file mode 100644 index 000000000000..9efd7948c547 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverride.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.aot.hint.annotation.Reflective; + +/** + * Mark a composed annotation as eligible for Bean Override processing. + * + *

    Specifying this annotation registers the configured {@link BeanOverrideProcessor} + * which must be capable of handling the composed annotation and its attributes. + * + *

    Since the composed annotation will typically only be applied to non-static + * fields, it is expected that the composed annotation is meta-annotated with + * {@link Target @Target(ElementType.FIELD)}. However, certain bean override + * annotations may be declared with an additional {@code ElementType.TYPE} target + * for use at the type level, as is the case for {@code @MockitoBean} which can + * be declared on a field, test class, or test interface. + * + *

    For concrete examples of such composed annotations, see + * {@link org.springframework.test.context.bean.override.convention.TestBean @TestBean}, + * {@link org.springframework.test.context.bean.override.mockito.MockitoBean @MockitoBean}, and + * {@link org.springframework.test.context.bean.override.mockito.MockitoSpyBean @MockitoSpyBean}. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.ANNOTATION_TYPE) +@Documented +@Reflective(BeanOverrideReflectiveProcessor.class) +public @interface BeanOverride { + + /** + * The {@link BeanOverrideProcessor} implementation to use. + */ + Class value(); + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessor.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessor.java new file mode 100644 index 000000000000..11d282f405df --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessor.java @@ -0,0 +1,497 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.springframework.aop.scope.ScopedProxyUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.NoUniqueBeanDefinitionException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.config.DependencyDescriptor; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanNameGenerator; +import org.springframework.beans.factory.support.DefaultBeanNameGenerator; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.context.aot.AbstractAotProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.ResolvableType; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * A {@link BeanFactoryPostProcessor} implementation that processes identified + * use of {@link BeanOverride @BeanOverride} and adapts the {@code BeanFactory} + * accordingly. + * + *

    For each override, the bean factory is prepared according to the chosen + * {@linkplain BeanOverrideStrategy override strategy}. The bean override instance + * is created, if necessary, and the related infrastructure is updated to allow + * the bean override instance to be injected into the corresponding + * {@linkplain BeanOverrideHandler#getField() field} of the test class. + * + *

    This processor does not work against a particular test class but rather + * only prepares the bean factory for the identified, unique set of bean overrides. + * + * @author Simon Baslé + * @author Stephane Nicoll + * @author Sam Brannen + * @author Yanming Zhou + * @since 6.2 + */ +class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, Ordered { + + private static final String PSEUDO_BEAN_NAME_PLACEHOLDER = "<<< PSEUDO BEAN NAME PLACEHOLDER >>>"; + + private static final BeanNameGenerator beanNameGenerator = DefaultBeanNameGenerator.INSTANCE; + + private final Set beanOverrideHandlers; + + private final BeanOverrideRegistry beanOverrideRegistry; + + + /** + * Create a new {@code BeanOverrideBeanFactoryPostProcessor} with the supplied + * set of {@link BeanOverrideHandler BeanOverrideHandlers} to process, using + * the given {@link BeanOverrideRegistry}. + * @param beanOverrideHandlers the bean override handlers to process + * @param beanOverrideRegistry the registry used to track bean override handlers + */ + BeanOverrideBeanFactoryPostProcessor(Set beanOverrideHandlers, + BeanOverrideRegistry beanOverrideRegistry) { + + this.beanOverrideHandlers = beanOverrideHandlers; + this.beanOverrideRegistry = beanOverrideRegistry; + } + + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE - 10; + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + Set generatedBeanNames = new HashSet<>(); + for (BeanOverrideHandler handler : this.beanOverrideHandlers) { + registerBeanOverride(beanFactory, handler, generatedBeanNames); + } + } + + private void registerBeanOverride(ConfigurableListableBeanFactory beanFactory, BeanOverrideHandler handler, + Set generatedBeanNames) { + + String beanName = handler.getBeanName(); + Assert.state(!BeanFactoryUtils.isFactoryDereference(beanName), () -> """ + Unable to override bean '%s'%s: a FactoryBean cannot be overridden. \ + To override the bean created by the FactoryBean, remove the '&' prefix.""" + .formatted(beanName, forField(handler.getField()))); + + switch (handler.getStrategy()) { + case REPLACE -> replaceOrCreateBean(beanFactory, handler, generatedBeanNames, true); + case REPLACE_OR_CREATE -> replaceOrCreateBean(beanFactory, handler, generatedBeanNames, false); + case WRAP -> wrapBean(beanFactory, handler); + } + } + + private void replaceOrCreateBean(ConfigurableListableBeanFactory beanFactory, BeanOverrideHandler handler, + Set generatedBeanNames, boolean requireExistingBean) { + + // NOTE: This method supports 3 distinct scenarios which must be accounted for. + // + // - JVM runtime + // - AOT processing + // - AOT runtime + // + // In addition, this method supports 4 distinct use cases. + // + // 1) Override existing bean by-type + // 2) Create bean by-type, with a generated name + // 3) Override existing bean by-name + // 4) Create bean by-name, with a provided name + + String beanName = handler.getBeanName(); + BeanDefinition existingBeanDefinition = null; + if (beanName == null) { + beanName = getBeanNameForType(beanFactory, handler, requireExistingBean); + // If the generatedBeanNames set already contains the beanName that we + // just found by-type, that means we are experiencing a "phantom read" + // (i.e., we found a bean that was not previously there). Consequently, + // we cannot "override the override", because we would lose one of the + // overrides. Instead, we must create a new override for the current + // handler. For example, if one handler creates an override for a SubType + // and a subsequent handler creates an override for a SuperType of that + // SubType, we must end up with overrides for both SuperType and SubType. + if (beanName != null && !generatedBeanNames.contains(beanName)) { + // 1) We are overriding an existing bean by-type. + beanName = BeanFactoryUtils.transformedBeanName(beanName); + // If we are overriding a manually registered singleton, we won't find + // an existing bean definition. + if (beanFactory.containsBeanDefinition(beanName)) { + existingBeanDefinition = beanFactory.getBeanDefinition(beanName); + } + } + else { + // 2) We are creating a bean by-type, with a generated name. + // Since NullAway will reject leaving the beanName set to null, + // we set it to a placeholder that will be replaced later. + beanName = PSEUDO_BEAN_NAME_PLACEHOLDER; + } + } + else { + Set candidates = getExistingBeanNamesByType(beanFactory, handler, false); + if (candidates.contains(beanName)) { + // 3) We are overriding an existing bean by-name. + existingBeanDefinition = beanFactory.getBeanDefinition(beanName); + } + else if (requireExistingBean) { + Field field = handler.getField(); + throw new IllegalStateException(""" + Unable to replace bean: there is no bean with name '%s' and type %s%s. \ + If the bean is defined in a @Bean method, make sure the return type is the \ + most specific type possible (for example, the concrete implementation type).""" + .formatted(beanName, handler.getBeanType(), requiredByField(field))); + } + // 4) We are creating a bean by-name with the provided beanName. + } + + if (existingBeanDefinition != null) { + // Validate the existing bean definition. + // + // Applies during "JVM runtime", "AOT processing", and "AOT runtime". + validateBeanDefinition(beanFactory, beanName); + } + else if (Boolean.getBoolean(AbstractAotProcessor.AOT_PROCESSING)) { + // There was no existing bean definition, but during "AOT processing" we + // do not register the "pseudo" bean definition since our AOT support + // cannot automatically convert that to a functional bean definition for + // use at "AOT runtime". Furthermore, by not registering a bean definition + // for a nonexistent bean, we allow the "JVM runtime" and "AOT runtime" + // to operate the same in the following else-block. + } + else { + // There was no existing bean definition, so we register a "pseudo" bean + // definition to ensure that a suitable bean definition exists for the given + // bean name for proper autowiring candidate resolution. + // + // Applies during "JVM runtime" and "AOT runtime". + + if (!(beanFactory instanceof BeanDefinitionRegistry registry)) { + throw new IllegalStateException("Cannot process bean override with a BeanFactory " + + "that does not implement BeanDefinitionRegistry: " + beanFactory.getClass().getName()); + } + + RootBeanDefinition pseudoBeanDefinition = createPseudoBeanDefinition(handler); + + // Generate a name for the nonexistent bean. + if (PSEUDO_BEAN_NAME_PLACEHOLDER.equals(beanName)) { + beanName = beanNameGenerator.generateBeanName(pseudoBeanDefinition, registry); + generatedBeanNames.add(beanName); + } + + registry.registerBeanDefinition(beanName, pseudoBeanDefinition); + } + + Object override = handler.createOverrideInstance(beanName, existingBeanDefinition, null, beanFactory); + this.beanOverrideRegistry.registerBeanOverrideHandler(handler, beanName); + + // Now we have an instance (the override) that we can manually register as a singleton. + // + // However, we need to remove any existing singleton instance -- for example, a + // manually registered singleton. + // + // As a bonus, by manually registering a singleton during "AOT processing", we allow + // GenericApplicationContext's preDetermineBeanType() method to transparently register + // runtime hints for a proxy generated by the above createOverrideInstance() invocation -- + // for example, when @MockitoBean creates a mock based on a JDK dynamic proxy. + if (beanFactory.containsSingleton(beanName)) { + destroySingleton(beanFactory, beanName); + } + beanFactory.registerSingleton(beanName, override); + } + + /** + * Check that a bean with the specified {@link BeanOverrideHandler#getBeanName() name} + * or {@link BeanOverrideHandler#getBeanType() type} has already been registered + * in the {@code BeanFactory}. + *

    If so, register the {@link BeanOverrideHandler} and the corresponding bean + * name in the {@link BeanOverrideRegistry}. + *

    The registry will later be checked to see if a given bean should be wrapped + * upon creation, during the early bean post-processing phase. + * @see BeanOverrideRegistry#registerBeanOverrideHandler(BeanOverrideHandler, String) + * @see WrapEarlyBeanPostProcessor#getEarlyBeanReference(Object, String) + */ + private void wrapBean(ConfigurableListableBeanFactory beanFactory, BeanOverrideHandler handler) { + String beanName = handler.getBeanName(); + Field field = handler.getField(); + ResolvableType beanType = handler.getBeanType(); + + if (beanName == null) { + // We are wrapping an existing bean by-type. + Set candidateNames = getExistingBeanNamesByType(beanFactory, handler, true); + String uniqueCandidate = determineUniqueCandidate(beanFactory, candidateNames, beanType, field); + if (uniqueCandidate != null) { + beanName = uniqueCandidate; + } + else { + String message = "Unable to select a bean to wrap: "; + int candidateCount = candidateNames.size(); + if (candidateCount == 0) { + message += """ + there are no beans of type %s%s. \ + If the bean is defined in a @Bean method, make sure the return type is the \ + most specific type possible (for example, the concrete implementation type).""" + .formatted(beanType, requiredByField(field)); + } + else { + message += "found %d beans of type %s%s: %s" + .formatted(candidateCount, beanType, requiredByField(field), candidateNames); + } + throw new IllegalStateException(message); + } + beanName = BeanFactoryUtils.transformedBeanName(beanName); + } + else { + // We are wrapping an existing bean by-name. + Set candidates = getExistingBeanNamesByType(beanFactory, handler, false); + if (!candidates.contains(beanName)) { + throw new IllegalStateException(""" + Unable to wrap bean: there is no bean with name '%s' and type %s%s. \ + If the bean is defined in a @Bean method, make sure the return type is the \ + most specific type possible (for example, the concrete implementation type).""" + .formatted(beanName, beanType, requiredByField(field))); + } + } + + validateBeanDefinition(beanFactory, beanName); + this.beanOverrideRegistry.registerBeanOverrideHandler(handler, beanName); + } + + @Nullable + private static String getBeanNameForType(ConfigurableListableBeanFactory beanFactory, BeanOverrideHandler handler, + boolean requireExistingBean) { + + Field field = handler.getField(); + ResolvableType beanType = handler.getBeanType(); + + Set candidateNames = getExistingBeanNamesByType(beanFactory, handler, true); + String uniqueCandidate = determineUniqueCandidate(beanFactory, candidateNames, beanType, field); + if (uniqueCandidate != null) { + return uniqueCandidate; + } + + int candidateCount = candidateNames.size(); + if (candidateCount == 0) { + if (requireExistingBean) { + throw new IllegalStateException(""" + Unable to override bean: there are no beans of type %s%s. \ + If the bean is defined in a @Bean method, make sure the return type is the \ + most specific type possible (for example, the concrete implementation type).""" + .formatted(beanType, requiredByField(field))); + } + return null; + } + + throw new IllegalStateException( + "Unable to select a bean to override: found %d beans of type %s%s: %s" + .formatted(candidateCount, beanType, requiredByField(field), candidateNames)); + } + + private static Set getExistingBeanNamesByType(ConfigurableListableBeanFactory beanFactory, + BeanOverrideHandler handler, boolean checkAutowiredCandidate) { + + Field field = handler.getField(); + ResolvableType resolvableType = handler.getBeanType(); + Class type = resolvableType.toClass(); + + // Start with matching bean names for type, excluding FactoryBeans. + Set beanNames = new LinkedHashSet<>( + Arrays.asList(beanFactory.getBeanNamesForType(resolvableType, true, false))); + + // Add matching FactoryBeans as well. + for (String beanName : beanFactory.getBeanNamesForType(FactoryBean.class, true, false)) { + beanName = BeanFactoryUtils.transformedBeanName(beanName); + Class producedType = beanFactory.getType(beanName, false); + if (type.equals(producedType)) { + beanNames.add(beanName); + } + } + + // Filter out non-matching autowire candidates. + if (field != null && checkAutowiredCandidate) { + DependencyDescriptor descriptor = new DependencyDescriptor(field, true); + beanNames.removeIf(beanName -> !beanFactory.isAutowireCandidate(beanName, descriptor)); + } + // Filter out scoped proxy targets. + beanNames.removeIf(ScopedProxyUtils::isScopedTarget); + + return beanNames; + } + + /** + * Determine the unique candidate in the given set of bean names. + *

    Honors both primary and fallback semantics, and + * otherwise matches against the field name as a fallback qualifier. + * @return the name of the unique candidate, or {@code null} if none found + * @since 6.2.3 + * @see org.springframework.beans.factory.support.DefaultListableBeanFactory#determineAutowireCandidate + */ + @Nullable + private static String determineUniqueCandidate(ConfigurableListableBeanFactory beanFactory, + Set candidateNames, ResolvableType beanType, @Nullable Field field) { + + // Step 0: none or only one + int candidateCount = candidateNames.size(); + if (candidateCount == 0) { + return null; + } + if (candidateCount == 1) { + return candidateNames.iterator().next(); + } + + // Step 1: check primary candidate + String primaryCandidate = determinePrimaryCandidate(beanFactory, candidateNames, beanType.toClass()); + if (primaryCandidate != null) { + return primaryCandidate; + } + + // Step 2: use the field name as a fallback qualifier + if (field != null) { + String fieldName = field.getName(); + if (candidateNames.contains(fieldName)) { + return fieldName; + } + } + + return null; + } + + /** + * Determine the primary candidate in the given set of bean names. + *

    Honors both primary and fallback semantics. + * @return the name of the primary candidate, or {@code null} if none found + * @see org.springframework.beans.factory.support.DefaultListableBeanFactory#determinePrimaryCandidate + */ + @Nullable + private static String determinePrimaryCandidate(ConfigurableListableBeanFactory beanFactory, + Set candidateBeanNames, Class beanType) { + + if (candidateBeanNames.isEmpty()) { + return null; + } + + String primaryBeanName = null; + // First pass: identify unique primary candidate + for (String candidateBeanName : candidateBeanNames) { + if (beanFactory.containsBeanDefinition(candidateBeanName)) { + BeanDefinition beanDefinition = beanFactory.getBeanDefinition(candidateBeanName); + if (beanDefinition.isPrimary()) { + if (primaryBeanName != null) { + throw new NoUniqueBeanDefinitionException(beanType, candidateBeanNames.size(), + "more than one 'primary' bean found among candidates: " + candidateBeanNames); + } + primaryBeanName = candidateBeanName; + } + } + } + // Second pass: identify unique non-fallback candidate + if (primaryBeanName == null) { + for (String candidateBeanName : candidateBeanNames) { + if (beanFactory.containsBeanDefinition(candidateBeanName)) { + BeanDefinition beanDefinition = beanFactory.getBeanDefinition(candidateBeanName); + if (!beanDefinition.isFallback()) { + if (primaryBeanName != null) { + // More than one non-fallback bean found among candidates. + return null; + } + primaryBeanName = candidateBeanName; + } + } + } + } + return primaryBeanName; + } + + /** + * Create a pseudo-{@link BeanDefinition} for the supplied {@link BeanOverrideHandler}, + * whose {@linkplain RootBeanDefinition#getTargetType() target type} and + * {@linkplain RootBeanDefinition#getQualifiedElement() qualified element} are + * the {@linkplain BeanOverrideHandler#getBeanType() bean type} and + * the {@linkplain BeanOverrideHandler#getField() field} of the {@code BeanOverrideHandler}, + * respectively. + *

    The returned bean definition should not be used to create + * a bean instance but rather only for the purpose of having suitable bean + * definition metadata available in the {@code BeanFactory} — for example, + * for autowiring candidate resolution. + */ + private static RootBeanDefinition createPseudoBeanDefinition(BeanOverrideHandler handler) { + RootBeanDefinition definition = new RootBeanDefinition(handler.getBeanType().resolve()); + definition.setTargetType(handler.getBeanType()); + definition.setQualifiedElement(handler.getField()); + return definition; + } + + /** + * Validate that the {@link BeanDefinition} for the supplied bean name is suitable + * for being replaced by a bean override. + *

    If there is no registered {@code BeanDefinition} for the supplied bean name, + * no validation is performed. + */ + private static void validateBeanDefinition(ConfigurableListableBeanFactory beanFactory, String beanName) { + // Due to https://github.com/spring-projects/spring-framework/issues/33800, we do NOT invoke + // beanFactory.isSingleton(beanName), since doing so can result in a BeanCreationException for + // certain beans -- for example, a Spring Data FactoryBean for a JpaRepository. + if (beanFactory.containsBeanDefinition(beanName)) { + BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName); + Assert.state(beanDefinition.isSingleton(), + () -> "Unable to override bean '" + beanName + "': only singleton beans can be overridden."); + } + } + + private static void destroySingleton(ConfigurableListableBeanFactory beanFactory, String beanName) { + if (!(beanFactory instanceof DefaultListableBeanFactory dlbf)) { + throw new IllegalStateException("Cannot process bean override with a BeanFactory " + + "that does not implement DefaultListableBeanFactory: " + beanFactory.getClass().getName()); + } + dlbf.destroySingleton(beanName); + } + + private static String forField(@Nullable Field field) { + if (field == null) { + return ""; + } + return " for field '%s.%s'".formatted(field.getDeclaringClass().getSimpleName(), field.getName()); + } + + private static String requiredByField(@Nullable Field field) { + if (field == null) { + return ""; + } + return " (as required by field '%s.%s')".formatted( + field.getDeclaringClass().getSimpleName(), field.getName()); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizer.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizer.java new file mode 100644 index 000000000000..0820042209d9 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizer.java @@ -0,0 +1,91 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.util.Set; + +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.MergedContextConfiguration; + +/** + * {@link ContextCustomizer} implementation that registers the necessary + * infrastructure to support {@linkplain BeanOverride Bean Overrides}. + * + * @author Simon Baslé + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + */ +class BeanOverrideContextCustomizer implements ContextCustomizer { + + static final String REGISTRY_BEAN_NAME = + "org.springframework.test.context.bean.override.internalBeanOverrideRegistry"; + + private static final String INFRASTRUCTURE_BEAN_NAME = + "org.springframework.test.context.bean.override.internalBeanOverridePostProcessor"; + + private static final String EARLY_INFRASTRUCTURE_BEAN_NAME = + "org.springframework.test.context.bean.override.internalWrapEarlyBeanPostProcessor"; + + + private final Set handlers; + + BeanOverrideContextCustomizer(Set handlers) { + this.handlers = handlers; + } + + @Override + public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) { + ConfigurableBeanFactory beanFactory = context.getBeanFactory(); + // Since all three Bean Override infrastructure beans are never injected as + // dependencies into other beans within the ApplicationContext, it is sufficient + // to register them as manual singleton instances. In addition, registration of + // the BeanOverrideBeanFactoryPostProcessor as a singleton is a requirement for + // AOT processing, since a bean definition cannot be generated for the + // Set argument that it accepts in its constructor. + BeanOverrideRegistry beanOverrideRegistry = new BeanOverrideRegistry(beanFactory); + beanFactory.registerSingleton(REGISTRY_BEAN_NAME, beanOverrideRegistry); + beanFactory.registerSingleton(INFRASTRUCTURE_BEAN_NAME, + new BeanOverrideBeanFactoryPostProcessor(this.handlers, beanOverrideRegistry)); + beanFactory.registerSingleton(EARLY_INFRASTRUCTURE_BEAN_NAME, + new WrapEarlyBeanPostProcessor(beanOverrideRegistry)); + } + + Set getBeanOverrideHandlers() { + return this.handlers; + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (other == null || other.getClass() != getClass()) { + return false; + } + BeanOverrideContextCustomizer that = (BeanOverrideContextCustomizer) other; + return this.handlers.equals(that.handlers); + } + + @Override + public int hashCode() { + return this.handlers.hashCode(); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizerFactory.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizerFactory.java new file mode 100644 index 000000000000..dfa9c9589eef --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizerFactory.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.lang.Nullable; +import org.springframework.test.context.ContextConfigurationAttributes; +import org.springframework.test.context.ContextCustomizerFactory; +import org.springframework.util.Assert; + +/** + * {@link ContextCustomizerFactory} implementation that provides support for + * {@linkplain BeanOverride Bean Overrides}. + * + * @author Simon Baslé + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + * @see BeanOverride + */ +class BeanOverrideContextCustomizerFactory implements ContextCustomizerFactory { + + @Override + @Nullable + public BeanOverrideContextCustomizer createContextCustomizer(Class testClass, + List configAttributes) { + + Set handlers = new LinkedHashSet<>(); + findBeanOverrideHandlers(testClass, handlers); + if (handlers.isEmpty()) { + return null; + } + return new BeanOverrideContextCustomizer(handlers); + } + + private void findBeanOverrideHandlers(Class testClass, Set handlers) { + BeanOverrideHandler.findAllHandlers(testClass).forEach(handler -> + Assert.state(handlers.add(handler), () -> + "Duplicate BeanOverrideHandler discovered in test class %s: %s" + .formatted(testClass.getName(), handler))); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideHandler.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideHandler.java new file mode 100644 index 000000000000..06eeaaf1fa97 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideHandler.java @@ -0,0 +1,371 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.SingletonBeanRegistry; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.style.ToStringCreator; +import org.springframework.lang.Nullable; +import org.springframework.test.context.TestContextAnnotationUtils; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +import static org.springframework.core.annotation.MergedAnnotations.SearchStrategy.DIRECT; + +/** + * Handler for Bean Override injection points that is responsible for creating + * the bean override instance for a given set of metadata and potentially for + * tracking the created instance. + * + *

    WARNING: Implementations are used as a cache key and must + * implement proper {@code equals()} and {@code hashCode()} methods based on the + * unique set of metadata used to identify the bean to override. Overridden + * {@code equals()} and {@code hashCode()} methods should also delegate to the + * {@code super} implementations in this class in order to support the basic + * metadata used by all bean overrides. In addition, it is recommended that + * implementations override {@code toString()} to include all relevant metadata + * in order to enhance diagnostics. + * + *

    Concrete implementations of {@code BeanOverrideHandler} can store additional + * metadata to use during override {@linkplain #createOverrideInstance instance + * creation} — for example, based on further processing of the annotation, + * the annotated field, or the annotated class. + * + *

    NOTE: Only singleton beans can be overridden. + * Any attempt to override a non-singleton bean will result in an exception. + * + * @author Simon Baslé + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + */ +public abstract class BeanOverrideHandler { + + private static final Comparator> reversedMetaDistance = + Comparator.> comparingInt(MergedAnnotation::getDistance).reversed(); + + + @Nullable + private final Field field; + + private final Set qualifierAnnotations; + + private final ResolvableType beanType; + + @Nullable + private final String beanName; + + private final BeanOverrideStrategy strategy; + + + protected BeanOverrideHandler(@Nullable Field field, ResolvableType beanType, @Nullable String beanName, + BeanOverrideStrategy strategy) { + + this.field = field; + this.qualifierAnnotations = getQualifierAnnotations(field); + this.beanType = beanType; + this.beanName = beanName; + this.strategy = strategy; + } + + /** + * Process the given {@code testClass} and build the corresponding + * {@code BeanOverrideHandler} list derived from {@link BeanOverride @BeanOverride} + * fields in the test class and its type hierarchy. + *

    This method does not search the enclosing class hierarchy and does not + * search for {@code @BeanOverride} declarations on classes or interfaces. + * @param testClass the test class to process + * @return a list of bean override handlers + * @see #findAllHandlers(Class) + */ + public static List forTestClass(Class testClass) { + return findHandlers(testClass, true); + } + + /** + * Process the given {@code testClass} and build the corresponding + * {@code BeanOverrideHandler} list derived from {@link BeanOverride @BeanOverride} + * fields in the test class and in its type hierarchy as well as from + * {@code @BeanOverride} declarations on classes and interfaces. + *

    This method additionally searches for {@code @BeanOverride} declarations + * in the enclosing class hierarchy based on + * {@link TestContextAnnotationUtils#searchEnclosingClass(Class)} semantics. + * @param testClass the test class to process + * @return a list of bean override handlers + * @since 6.2.2 + */ + static List findAllHandlers(Class testClass) { + return findHandlers(testClass, false); + } + + private static List findHandlers(Class testClass, boolean localFieldsOnly) { + List handlers = new ArrayList<>(); + findHandlers(testClass, testClass, handlers, localFieldsOnly, new HashSet<>()); + return handlers; + } + + /** + * Find handlers using tail recursion to ensure that "locally declared" bean overrides + * take precedence over inherited bean overrides. + *

    Note: the search algorithm is effectively the inverse of the algorithm used in + * {@link org.springframework.test.context.TestContextAnnotationUtils#findAnnotationDescriptor(Class, Class)}, + * but with tail recursion the semantics should be the same. + * @param clazz the class in/on which to search + * @param testClass the original test class + * @param handlers the list of handlers found + * @param localFieldsOnly whether to search only on local fields within the type hierarchy + * @param visitedEnclosingClasses the set of enclosing classes already visited + * @since 6.2.2 + */ + private static void findHandlers(Class clazz, Class testClass, List handlers, + boolean localFieldsOnly, Set> visitedEnclosingClasses) { + + // 1) Search enclosing class hierarchy. + if (!localFieldsOnly && TestContextAnnotationUtils.searchEnclosingClass(clazz)) { + Class enclosingClass = clazz.getEnclosingClass(); + if (visitedEnclosingClasses.add(enclosingClass)) { + findHandlers(enclosingClass, testClass, handlers, localFieldsOnly, visitedEnclosingClasses); + } + } + + // 2) Search class hierarchy. + Class superclass = clazz.getSuperclass(); + if (superclass != null && superclass != Object.class) { + findHandlers(superclass, testClass, handlers, localFieldsOnly, visitedEnclosingClasses); + } + + if (!localFieldsOnly) { + // 3) Search interfaces. + for (Class ifc : clazz.getInterfaces()) { + findHandlers(ifc, testClass, handlers, localFieldsOnly, visitedEnclosingClasses); + } + + // 4) Process current class. + processClass(clazz, testClass, handlers); + } + + // 5) Process fields in current class. + ReflectionUtils.doWithLocalFields(clazz, field -> processField(field, testClass, handlers)); + } + + private static void processClass(Class clazz, Class testClass, List handlers) { + processElement(clazz, testClass, (processor, composedAnnotation) -> + processor.createHandlers(composedAnnotation, testClass).forEach(handlers::add)); + } + + private static void processField(Field field, Class testClass, List handlers) { + AtomicBoolean overrideAnnotationFound = new AtomicBoolean(); + processElement(field, testClass, (processor, composedAnnotation) -> { + Assert.state(!Modifier.isStatic(field.getModifiers()), + () -> "@BeanOverride field must not be static: " + field); + Assert.state(overrideAnnotationFound.compareAndSet(false, true), + () -> "Multiple @BeanOverride annotations found on field: " + field); + handlers.add(processor.createHandler(composedAnnotation, testClass, field)); + }); + } + + private static void processElement(AnnotatedElement element, Class testClass, + BiConsumer consumer) { + + MergedAnnotations.from(element, DIRECT) + .stream(BeanOverride.class) + .sorted(reversedMetaDistance) + .forEach(mergedAnnotation -> { + MergedAnnotation metaSource = mergedAnnotation.getMetaSource(); + Assert.state(metaSource != null, "@BeanOverride annotation must be meta-present"); + + BeanOverride beanOverride = mergedAnnotation.synthesize(); + BeanOverrideProcessor processor = BeanUtils.instantiateClass(beanOverride.value()); + Annotation composedAnnotation = metaSource.synthesize(); + consumer.accept(processor, composedAnnotation); + }); + } + + + /** + * Get the annotated {@link Field}. + */ + @Nullable + public final Field getField() { + return this.field; + } + + /** + * Get the bean {@linkplain ResolvableType type} to override. + */ + public final ResolvableType getBeanType() { + return this.beanType; + } + + /** + * Get the bean name to override, or {@code null} to look for a single + * matching bean of type {@link #getBeanType()}. + */ + @Nullable + public final String getBeanName() { + return this.beanName; + } + + /** + * Get the {@link BeanOverrideStrategy} for this {@code BeanOverrideHandler}, + * which influences how and when the bean override instance should be created. + */ + public final BeanOverrideStrategy getStrategy() { + return this.strategy; + } + + /** + * {@linkplain #createOverrideInstance Create} and + * {@linkplain #trackOverrideInstance track} a bean override instance for an + * existing {@link BeanDefinition} or an existing singleton bean, based on the + * metadata in this {@code BeanOverrideHandler}. + * @param beanName the name of the bean being overridden + * @param existingBeanDefinition an existing bean definition for the supplied + * bean name, or {@code null} if not available or not relevant + * @param existingBeanInstance an existing instance for the supplied bean name + * for wrapping purposes, or {@code null} if not available or not relevant + * @param singletonBeanRegistry a registry in which this handler can store + * tracking state in the form of a singleton bean + * @return the instance with which to override the bean + * @see #trackOverrideInstance(Object, SingletonBeanRegistry) + * @see #createOverrideInstance(String, BeanDefinition, Object) + */ + final Object createOverrideInstance( + String beanName, @Nullable BeanDefinition existingBeanDefinition, + @Nullable Object existingBeanInstance, SingletonBeanRegistry singletonBeanRegistry) { + + Object override = createOverrideInstance(beanName, existingBeanDefinition, existingBeanInstance); + trackOverrideInstance(override, singletonBeanRegistry); + return override; + } + + /** + * Create a bean override instance for an existing {@link BeanDefinition} or + * an existing singleton bean, based on the metadata in this + * {@code BeanOverrideHandler}. + * @param beanName the name of the bean being overridden + * @param existingBeanDefinition an existing bean definition for the supplied + * bean name, or {@code null} if not available or not relevant + * @param existingBeanInstance an existing instance for the supplied bean name + * for wrapping purposes, or {@code null} if not available or not relevant + * @return the instance with which to override the bean + * @see #trackOverrideInstance(Object, SingletonBeanRegistry) + */ + protected abstract Object createOverrideInstance(String beanName, + @Nullable BeanDefinition existingBeanDefinition, @Nullable Object existingBeanInstance); + + /** + * Track the supplied bean override instance that was created by this + * {@code BeanOverrideHandler}. + *

    The default implementation does not track the supplied instance, but + * this can be overridden in subclasses as appropriate. + * @param override the bean override instance to track + * @param singletonBeanRegistry a registry in which this handler can store + * tracking state in the form of a singleton bean + * @see #createOverrideInstance(String, BeanDefinition, Object) + */ + protected void trackOverrideInstance(Object override, SingletonBeanRegistry singletonBeanRegistry) { + // NO-OP + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (other == null || other.getClass() != getClass()) { + return false; + } + BeanOverrideHandler that = (BeanOverrideHandler) other; + if (!Objects.equals(this.beanType.getType(), that.beanType.getType()) || + !Objects.equals(this.beanName, that.beanName) || + !Objects.equals(this.strategy, that.strategy)) { + return false; + } + + // by-name lookup + if (this.beanName != null) { + return true; + } + + // by-type lookup + if (this.field == null) { + return (that.field == null); + } + return (that.field != null && this.field.getName().equals(that.field.getName()) && + this.qualifierAnnotations.equals(that.qualifierAnnotations)); + } + + @Override + public int hashCode() { + int hash = Objects.hash(getClass(), this.beanType.getType(), this.beanName, this.strategy); + return (this.beanName != null ? hash : hash + + Objects.hash((this.field != null ? this.field.getName() : null), this.qualifierAnnotations)); + } + + @Override + public String toString() { + return new ToStringCreator(this) + .append("field", this.field) + .append("beanType", this.beanType) + .append("beanName", this.beanName) + .append("strategy", this.strategy) + .toString(); + } + + + private static Set getQualifierAnnotations(@Nullable Field field) { + if (field == null) { + return Collections.emptySet(); + } + Annotation[] candidates = field.getDeclaredAnnotations(); + if (candidates.length == 0) { + return Collections.emptySet(); + } + Set annotations = new HashSet<>(candidates.length - 1); + for (Annotation candidate : candidates) { + // Assume all non-BeanOverride annotations are "qualifiers". + if (!isBeanOverrideAnnotation(candidate.annotationType())) { + annotations.add(candidate); + } + } + return annotations; + } + + private static boolean isBeanOverrideAnnotation(Class type) { + return MergedAnnotations.from(type).isPresent(BeanOverride.class); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideProcessor.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideProcessor.java new file mode 100644 index 000000000000..2e1f69ec90f7 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideProcessor.java @@ -0,0 +1,83 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.List; + +/** + * Strategy interface for Bean Override processing, which creates + * {@link BeanOverrideHandler} instances that drive how target beans are + * overridden. + * + *

    At least one composed annotation that is meta-annotated with + * {@link BeanOverride @BeanOverride} must be a companion of this processor and + * may optionally provide annotation attributes that can be used to configure the + * {@code BeanOverrideHandler}. + * + *

    Implementations are required to have a no-argument constructor and be + * stateless. + * + * @author Simon Baslé + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + */ +public interface BeanOverrideProcessor { + + /** + * Create a {@link BeanOverrideHandler} for the given annotated field. + *

    This method will only be invoked when a {@link BeanOverride @BeanOverride} + * annotation is declared on a field — for example, if the supplied field + * is annotated with {@code @MockitoBean}. + * @param overrideAnnotation the composed annotation that declares the + * {@code @BeanOverride} annotation which registers this processor + * @param testClass the test class to process + * @param field the annotated field + * @return the {@code BeanOverrideHandler} that should handle the given field + * @see #createHandlers(Annotation, Class) + */ + BeanOverrideHandler createHandler(Annotation overrideAnnotation, Class testClass, Field field); + + /** + * Create a list of {@link BeanOverrideHandler} instances for the given override + * annotation and test class. + *

    This method will only be invoked when a {@link BeanOverride @BeanOverride} + * annotation is declared at the type level — for example, if the supplied + * test class is annotated with {@code @MockitoBean}. + *

    Note that the test class may not be directly annotated with the override + * annotation. For example, the override annotation may have been declared + * on an interface, superclass, or enclosing class within the test class + * hierarchy. + *

    The default implementation returns an empty list, signaling that this + * {@code BeanOverrideProcessor} does not support type-level {@code @BeanOverride} + * declarations. Can be overridden by concrete implementations to support + * type-level use cases. + * @param overrideAnnotation the composed annotation that declares the + * {@code @BeanOverride} annotation which registers this processor + * @param testClass the test class to process + * @return the list of {@code BeanOverrideHandlers} for the annotated class + * @since 6.2.2 + * @see #createHandler(Annotation, Class, Field) + */ + default List createHandlers(Annotation overrideAnnotation, Class testClass) { + return Collections.emptyList(); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideReflectiveProcessor.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideReflectiveProcessor.java new file mode 100644 index 000000000000..787fcd0ff3ce --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideReflectiveProcessor.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.lang.reflect.AnnotatedElement; + +import org.springframework.aot.hint.ReflectionHints; +import org.springframework.aot.hint.annotation.ReflectiveProcessor; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; + +import static org.springframework.aot.hint.MemberCategory.INVOKE_DECLARED_CONSTRUCTORS; + +/** + * {@link ReflectiveProcessor} that processes {@link BeanOverride @BeanOverride} + * annotations. + * + * @author Sam Brannen + * @since 6.2 + */ +class BeanOverrideReflectiveProcessor implements ReflectiveProcessor { + + @Override + public void registerReflectionHints(ReflectionHints hints, AnnotatedElement element) { + MergedAnnotations.from(element) + .get(BeanOverride.class) + .synthesize(MergedAnnotation::isPresent) + .map(BeanOverride::value) + .ifPresent(clazz -> hints.registerType(clazz, INVOKE_DECLARED_CONSTRUCTORS)); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideRegistry.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideRegistry.java new file mode 100644 index 000000000000..3afc7c885af1 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideRegistry.java @@ -0,0 +1,129 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.lang.reflect.Field; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * An internal class used to track {@link BeanOverrideHandler}-related state after + * the bean factory has been processed and to provide field injection utilities + * for test execution listeners. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + */ +class BeanOverrideRegistry { + + private static final Log logger = LogFactory.getLog(BeanOverrideRegistry.class); + + + private final Map handlerToBeanNameMap = new LinkedHashMap<>(); + + private final Map wrappingBeanOverrideHandlers = new LinkedHashMap<>(); + + private final ConfigurableBeanFactory beanFactory; + + + BeanOverrideRegistry(ConfigurableBeanFactory beanFactory) { + Assert.notNull(beanFactory, "ConfigurableBeanFactory must not be null"); + this.beanFactory = beanFactory; + } + + /** + * Register the provided {@link BeanOverrideHandler} and associate it with the + * given {@code beanName}. + *

    Also associates a {@linkplain BeanOverrideStrategy#WRAP "wrapping"} handler + * with the given {@code beanName}, allowing for subsequent wrapping of the + * bean via {@link #wrapBeanIfNecessary(Object, String)}. + */ + void registerBeanOverrideHandler(BeanOverrideHandler handler, String beanName) { + Assert.state(!this.handlerToBeanNameMap.containsKey(handler), () -> + "Cannot register BeanOverrideHandler for bean with name '%s'; detected multiple registrations for %s" + .formatted(beanName, handler)); + + // Check if beanName was already registered, before adding the new mapping. + boolean beanNameAlreadyRegistered = this.handlerToBeanNameMap.containsValue(beanName); + // Add new mapping before potentially logging a warning, to ensure that + // the current handler is logged as well. + this.handlerToBeanNameMap.put(handler, beanName); + + if (beanNameAlreadyRegistered && logger.isWarnEnabled()) { + List competingHandlers = this.handlerToBeanNameMap.entrySet().stream() + .filter(entry -> entry.getValue().equals(beanName)) + .map(Entry::getKey) + .toList(); + logger.warn("Bean with name '%s' was overridden by multiple handlers: %s" + .formatted(beanName, competingHandlers)); + } + + if (handler.getStrategy() == BeanOverrideStrategy.WRAP) { + this.wrappingBeanOverrideHandlers.put(beanName, handler); + } + } + + /** + * Use the registered {@linkplain BeanOverrideStrategy#WRAP "wrapping"} + * {@link BeanOverrideHandler} to create an override instance by wrapping the + * supplied bean. + *

    If no suitable {@code BeanOverrideHandler} has been registered, this + * method returns the supplied bean unmodified. + * @see #registerBeanOverrideHandler(BeanOverrideHandler, String) + */ + Object wrapBeanIfNecessary(Object bean, String beanName) { + if (!this.wrappingBeanOverrideHandlers.containsKey(beanName)) { + return bean; + } + BeanOverrideHandler handler = this.wrappingBeanOverrideHandlers.get(beanName); + Assert.state(handler != null, + () -> "Failed to find wrapping BeanOverrideHandler for bean '" + beanName + "'"); + return handler.createOverrideInstance(beanName, null, bean, this.beanFactory); + } + + void inject(Object target, BeanOverrideHandler handler) { + Field field = handler.getField(); + Assert.notNull(field, () -> "BeanOverrideHandler must have a non-null field: " + handler); + String beanName = this.handlerToBeanNameMap.get(handler); + Assert.state(StringUtils.hasLength(beanName), () -> "No bean found for BeanOverrideHandler: " + handler); + inject(field, target, beanName); + } + + private void inject(Field field, Object target, String beanName) { + try { + Object bean = this.beanFactory.getBean(beanName, field.getType()); + ReflectionUtils.makeAccessible(field); + ReflectionUtils.setField(field, target, bean); + } + catch (Throwable ex) { + throw new BeanCreationException("Could not inject field '" + field + "'", ex); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideStrategy.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideStrategy.java new file mode 100644 index 000000000000..b0f9437fb5e0 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideStrategy.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +/** + * Strategies for bean override processing. + * + * @author Simon Baslé + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + */ +public enum BeanOverrideStrategy { + + /** + * Replace a given bean, immediately preparing a singleton instance. + *

    Fails if the original bean does not exist. To create a new bean + * in such a case, use {@link #REPLACE_OR_CREATE} instead. + */ + REPLACE, + + /** + * Replace or create a given bean, immediately preparing a singleton instance. + *

    Contrary to {@link #REPLACE}, this strategy creates a new bean if the + * target bean does not exist rather than failing. + */ + REPLACE_OR_CREATE, + + /** + * Intercept and process an early bean reference, allowing variants of bean + * overriding to wrap the original bean instance — for example, to + * delegate to actual methods in the context of a mocking "spy". + */ + WRAP + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideTestExecutionListener.java new file mode 100644 index 000000000000..736223358cce --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideTestExecutionListener.java @@ -0,0 +1,102 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.util.List; + +import org.springframework.test.context.TestContext; +import org.springframework.test.context.support.AbstractTestExecutionListener; +import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; + +/** + * {@code TestExecutionListener} that enables {@link BeanOverride @BeanOverride} + * support in tests, by injecting overridden beans in appropriate fields of the + * test instance. + * + * @author Simon Baslé + * @author Sam Brannen + * @author Phillip Webb + * @author Andy Wilkinson + * @author Moritz Halbritter + * @since 6.2 + */ +public class BeanOverrideTestExecutionListener extends AbstractTestExecutionListener { + + /** + * The {@link #getOrder() order} value for this listener: {@value}. + * @since 6.2.3 + */ + public static final int ORDER = 1950; + + /** + * Returns {@value #ORDER}, which ensures that the {@code BeanOverrideTestExecutionListener} + * is ordered after the + * {@link org.springframework.test.context.support.DirtiesContextBeforeModesTestExecutionListener + * DirtiesContextBeforeModesTestExecutionListener} and before the + * {@link DependencyInjectionTestExecutionListener}. + */ + @Override + public int getOrder() { + return ORDER; + } + + /** + * Inject each {@link BeanOverride @BeanOverride} field in the + * {@linkplain Object test instance} of the supplied {@linkplain TestContext + * test context} with a corresponding bean override instance. + */ + @Override + public void prepareTestInstance(TestContext testContext) throws Exception { + injectFields(testContext); + } + + /** + * Re-inject each {@link BeanOverride @BeanOverride} field in the + * {@linkplain Object test instance} of the supplied {@linkplain TestContext + * test context} with a corresponding bean override instance. + *

    This method does nothing if the + * {@link DependencyInjectionTestExecutionListener#REINJECT_DEPENDENCIES_ATTRIBUTE + * REINJECT_DEPENDENCIES_ATTRIBUTE} attribute is not present in the + * {@code TestContext} with a value of {@link Boolean#TRUE}. + */ + @Override + public void beforeTestMethod(TestContext testContext) throws Exception { + Object reinjectDependenciesAttribute = testContext.getAttribute( + DependencyInjectionTestExecutionListener.REINJECT_DEPENDENCIES_ATTRIBUTE); + if (Boolean.TRUE.equals(reinjectDependenciesAttribute)) { + injectFields(testContext); + } + } + + /** + * Inject each {@link BeanOverride @BeanOverride} field in the test instance with + * a corresponding bean override instance. + */ + private static void injectFields(TestContext testContext) { + List handlers = BeanOverrideHandler.forTestClass(testContext.getTestClass()); + if (!handlers.isEmpty()) { + Object testInstance = testContext.getTestInstance(); + BeanOverrideRegistry beanOverrideRegistry = testContext.getApplicationContext() + .getBean(BeanOverrideContextCustomizer.REGISTRY_BEAN_NAME, BeanOverrideRegistry.class); + + for (BeanOverrideHandler handler : handlers) { + beanOverrideRegistry.inject(testInstance, handler); + } + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/WrapEarlyBeanPostProcessor.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/WrapEarlyBeanPostProcessor.java new file mode 100644 index 000000000000..1164c89ce6dd --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/WrapEarlyBeanPostProcessor.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.config.SmartInstantiationAwareBeanPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.PriorityOrdered; +import org.springframework.util.StringUtils; + +/** + * {@link SmartInstantiationAwareBeanPostProcessor} implementation that wraps + * beans in order to support the {@link BeanOverrideStrategy#WRAP WRAP} bean + * override strategy. + * + * @author Simon Baslé + * @author Stephane Nicoll + * @since 6.2 + */ +class WrapEarlyBeanPostProcessor implements SmartInstantiationAwareBeanPostProcessor, PriorityOrdered { + + private final Map earlyReferences = new ConcurrentHashMap<>(16); + + private final BeanOverrideRegistry beanOverrideRegistry; + + WrapEarlyBeanPostProcessor(BeanOverrideRegistry beanOverrideRegistry) { + this.beanOverrideRegistry = beanOverrideRegistry; + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } + + @Override + public Object getEarlyBeanReference(Object bean, String beanName) throws BeansException { + if (bean instanceof FactoryBean) { + return bean; + } + this.earlyReferences.put(getCacheKey(bean, beanName), bean); + return this.beanOverrideRegistry.wrapBeanIfNecessary(bean, beanName); + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof FactoryBean) { + return bean; + } + if (this.earlyReferences.remove(getCacheKey(bean, beanName)) != bean) { + return this.beanOverrideRegistry.wrapBeanIfNecessary(bean, beanName); + } + return bean; + } + + private String getCacheKey(Object bean, String beanName) { + return (StringUtils.hasLength(beanName) ? beanName : bean.getClass().getName()); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java new file mode 100644 index 000000000000..9393a17ed0cb --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java @@ -0,0 +1,178 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.aot.hint.annotation.Reflective; +import org.springframework.core.annotation.AliasFor; +import org.springframework.test.context.bean.override.BeanOverride; + +/** + * {@code @TestBean} is an annotation that can be applied to a non-static field + * in a test class to override a bean in the test's + * {@link org.springframework.context.ApplicationContext ApplicationContext} + * using a static factory method. + * + *

    By default, the bean to override is inferred from the type of the annotated + * field. If multiple candidates exist, a {@code @Qualifier} annotation can be + * used to help disambiguate. In the absence of a {@code @Qualifier} annotation, + * the name of the annotated field will be used as a fallback qualifier. + * Alternatively, you can explicitly specify a bean name to replace by setting the + * {@link #value() value} or {@link #name() name} attribute. + * + *

    A bean will be created if a corresponding bean does not exist. However, if + * you would like for the test to fail when a corresponding bean does not exist, + * you can set the {@link #enforceOverride() enforceOverride} attribute to {@code true} + * — for example, {@code @TestBean(enforceOverride = true)}. + * + *

    The instance is created from a zero-argument static factory method whose + * return type is compatible with the annotated field. The factory method can be + * declared directly in the class which declares the {@code @TestBean} field or + * within the type hierarchy above that class, including implemented interfaces. + * If the {@code @TestBean} field is declared in a nested test class, the enclosing + * class hierarchy is also searched. Alternatively, a factory method in an external + * class can be referenced via its fully-qualified method name following the syntax + * {@code #} — for example, + * {@code @TestBean(methodName = "org.example.TestUtils#createCustomerRepository")}. + * + *

    The factory method is deduced as follows. + * + *

    + * + *

    Consider the following example. + * + *

     class CustomerServiceTests {
    + *
    + *     @TestBean
    + *     private CustomerRepository repository;
    + *
    + *     // @Test methods ...
    + *
    + *     private static CustomerRepository repository() {
    + *         return new TestCustomerRepository();
    + *     }
    + * }
    + * + *

    In the example above, the {@code repository} bean is replaced by the + * instance generated by the {@code repository()} method. Not only is the + * overridden instance injected into the {@code repository} field, but it is + * also replaced in the {@code BeanFactory} so that other injection points for + * that bean use the overridden bean instance. + * + *

    To make things more explicit, the bean and method names can be set, + * as shown in the following example. + * + *

     class CustomerServiceTests {
    + *
    + *     @TestBean(name = "customerRepository", methodName = "createTestCustomerRepository")
    + *     CustomerRepository repository;
    + *
    + *     // @Test methods ...
    + *
    + *     static CustomerRepository createTestCustomerRepository() {
    + *         return new TestCustomerRepository();
    + *     }
    + * }
    + * + *

    NOTE: Only singleton beans can be overridden. + * Any attempt to override a non-singleton bean will result in an exception. When + * overriding a bean created by a {@link org.springframework.beans.factory.FactoryBean + * FactoryBean}, the {@code FactoryBean} will be replaced with a singleton bean + * corresponding to the value returned from the {@code @TestBean} factory method. + * + *

    There are no restrictions on the visibility of {@code @TestBean} fields or + * factory methods. Such fields and methods can therefore be {@code public}, + * {@code protected}, package-private (default visibility), or {@code private} + * depending on the needs or coding practices of the project. + * + *

    {@code @TestBean} fields will be inherited from an enclosing test class by default. See + * {@link org.springframework.test.context.NestedTestConfiguration @NestedTestConfiguration} + * for details. + * + * @author Simon Baslé + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + * @see org.springframework.test.context.bean.override.mockito.MockitoBean @MockitoBean + * @see org.springframework.test.context.bean.override.mockito.MockitoSpyBean @MockitoSpyBean + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@BeanOverride(TestBeanOverrideProcessor.class) +@Reflective(TestBeanReflectiveProcessor.class) +public @interface TestBean { + + /** + * Alias for {@link #name()}. + *

    Intended to be used when no other attributes are needed — for + * example, {@code @TestBean("customBeanName")}. + * @see #name() + */ + @AliasFor("name") + String value() default ""; + + /** + * Name of the bean to override. + *

    If left unspecified, the bean to override is selected according to + * the annotated field's type, taking qualifiers into account if necessary. + * See the {@linkplain TestBean class-level documentation} for details. + * @see #value() + */ + @AliasFor("value") + String name() default ""; + + /** + * Name of the static factory method that will be used to instantiate the bean + * to override. + *

    A search will be performed to find the factory method in the class in + * which the {@code @TestBean} field is declared, in one of its superclasses, + * or in any implemented interfaces. If the {@code @TestBean} field is declared + * in a nested test class, the enclosing class hierarchy will also be searched. + *

    Alternatively, a factory method in an external class can be referenced + * via its fully-qualified method name following the syntax + * {@code #} — for example, + * {@code @TestBean(methodName = "org.example.TestUtils#createCustomerRepository")}. + *

    If left unspecified, the name of the factory method will be detected + * based either on the name of the {@code @TestBean} field or the {@link #name() name} + * of the bean. + */ + String methodName() default ""; + + /** + * Whether to require the existence of the bean being overridden. + *

    Defaults to {@code false} which means that a bean will be created if a + * corresponding bean does not exist. + *

    Set to {@code true} to cause an exception to be thrown if a corresponding + * bean does not exist. + * @see org.springframework.test.context.bean.override.BeanOverrideStrategy#REPLACE_OR_CREATE + * @see org.springframework.test.context.bean.override.BeanOverrideStrategy#REPLACE + */ + boolean enforceOverride() default false; + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideHandler.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideHandler.java new file mode 100644 index 000000000000..20df24ea8850 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideHandler.java @@ -0,0 +1,98 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Objects; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.core.ResolvableType; +import org.springframework.core.style.ToStringCreator; +import org.springframework.lang.Nullable; +import org.springframework.test.context.bean.override.BeanOverrideHandler; +import org.springframework.test.context.bean.override.BeanOverrideStrategy; +import org.springframework.util.ReflectionUtils; + +/** + * {@link BeanOverrideHandler} implementation for {@link TestBean @TestBean}. + * + * @author Simon Baslé + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + */ +final class TestBeanOverrideHandler extends BeanOverrideHandler { + + private final Method factoryMethod; + + + TestBeanOverrideHandler(Field field, ResolvableType beanType, @Nullable String beanName, + BeanOverrideStrategy strategy, Method factoryMethod) { + + super(field, beanType, beanName, strategy); + this.factoryMethod = factoryMethod; + } + + + @Override + protected Object createOverrideInstance(String beanName, @Nullable BeanDefinition existingBeanDefinition, + @Nullable Object existingBeanInstance) { + + try { + ReflectionUtils.makeAccessible(this.factoryMethod); + return this.factoryMethod.invoke(null); + } + catch (IllegalAccessException | InvocationTargetException ex) { + throw new IllegalStateException( + "Failed to invoke @TestBean factory method: " + this.factoryMethod, ex); + } + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + if (!super.equals(other)) { + return false; + } + TestBeanOverrideHandler that = (TestBeanOverrideHandler) other; + return Objects.equals(this.factoryMethod, that.factoryMethod); + } + + @Override + public int hashCode() { + return this.factoryMethod.hashCode() * 29 + super.hashCode(); + } + + @Override + public String toString() { + return new ToStringCreator(this) + .append("field", getField()) + .append("beanType", getBeanType()) + .append("beanName", getBeanName()) + .append("strategy", getStrategy()) + .append("factoryMethod", this.factoryMethod) + .toString(); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessor.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessor.java new file mode 100644 index 000000000000..a47d491b8452 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessor.java @@ -0,0 +1,178 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.core.MethodIntrospector; +import org.springframework.core.ResolvableType; +import org.springframework.test.context.TestContextAnnotationUtils; +import org.springframework.test.context.bean.override.BeanOverrideProcessor; +import org.springframework.test.context.bean.override.BeanOverrideStrategy; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.ReflectionUtils.MethodFilter; + +import static org.springframework.test.context.bean.override.BeanOverrideStrategy.REPLACE; +import static org.springframework.test.context.bean.override.BeanOverrideStrategy.REPLACE_OR_CREATE; + +/** + * {@link BeanOverrideProcessor} implementation for {@link TestBean @TestBean} + * support, which creates a {@link TestBeanOverrideHandler} for annotated + * fields in a given class and ensures that a corresponding static factory method + * exists, according to the {@linkplain TestBean documented conventions}. + * + * @author Simon Baslé + * @author Sam Brannen + * @author Stephane Nicoll + * @since 6.2 + */ +class TestBeanOverrideProcessor implements BeanOverrideProcessor { + + @Override + public TestBeanOverrideHandler createHandler(Annotation overrideAnnotation, Class testClass, Field field) { + if (!(overrideAnnotation instanceof TestBean testBean)) { + throw new IllegalStateException("Invalid annotation passed to %s: expected @TestBean on field %s.%s" + .formatted(getClass().getSimpleName(), field.getDeclaringClass().getName(), field.getName())); + } + + String beanName = (!testBean.name().isBlank() ? testBean.name() : null); + String methodName = testBean.methodName(); + BeanOverrideStrategy strategy = (testBean.enforceOverride() ? REPLACE : REPLACE_OR_CREATE); + + Method factoryMethod; + if (!methodName.isBlank()) { + // If the user specified an explicit method name, search for that. + factoryMethod = findTestBeanFactoryMethod(field.getDeclaringClass(), field.getType(), methodName); + } + else { + // Otherwise, search for candidate factory methods whose names match either + // the field name or the explicit bean name (if any). + List candidateMethodNames = new ArrayList<>(); + candidateMethodNames.add(field.getName()); + + if (beanName != null) { + candidateMethodNames.add(beanName); + } + factoryMethod = findTestBeanFactoryMethod(field.getDeclaringClass(), field.getType(), candidateMethodNames); + } + + return new TestBeanOverrideHandler( + field, ResolvableType.forField(field, testClass), beanName, strategy, factoryMethod); + } + + /** + * Find a test bean factory {@link Method} for the given {@link Class}. + *

    Delegates to {@link #findTestBeanFactoryMethod(Class, Class, Collection)}. + */ + Method findTestBeanFactoryMethod(Class clazz, Class methodReturnType, String... methodNames) { + return findTestBeanFactoryMethod(clazz, methodReturnType, List.of(methodNames)); + } + + /** + * Find a test bean factory {@link Method} for the given {@link Class}, which + * meets the following criteria. + *

      + *
    • The method is static.
    • + *
    • The method does not accept any arguments.
    • + *
    • The method's return type matches the supplied {@code methodReturnType}.
    • + *
    • The method's name is one of the supplied {@code methodNames}.
    • + *
    + *

    This method traverses up the type hierarchy of the given class in search + * of the factory method, beginning with the class itself and then searching + * implemented interfaces and superclasses. If a factory method is not found + * in the type hierarchy, this method will also search the enclosing class + * hierarchy if the class is a nested class. + *

    If multiple factory methods are found that match the search criteria, + * an exception is thrown. + * @param clazz the class in which to search for the factory method + * @param methodReturnType the return type for the factory method + * @param methodNames a set of supported names for the factory method + * @return the corresponding factory method + * @throws IllegalStateException if a matching factory method cannot + * be found or multiple methods match + */ + Method findTestBeanFactoryMethod(Class clazz, Class methodReturnType, Collection methodNames) { + Assert.notEmpty(methodNames, "At least one candidate method name is required"); + Set methods = new LinkedHashSet<>(); + Set originalNames = new LinkedHashSet<>(methodNames); + + // Process fully-qualified method names first. + for (String methodName : methodNames) { + int indexOfHash = methodName.lastIndexOf('#'); + if (indexOfHash != -1) { + String className = methodName.substring(0, indexOfHash).trim(); + Assert.hasText(className, () -> "No class name present in fully-qualified method name: " + methodName); + String methodNameToUse = methodName.substring(indexOfHash + 1).trim(); + Assert.hasText(methodNameToUse, () -> "No method name present in fully-qualified method name: " + methodName); + Class declaringClass; + try { + declaringClass = ClassUtils.forName(className, getClass().getClassLoader()); + } + catch (ClassNotFoundException | LinkageError ex) { + throw new IllegalStateException( + "Failed to load class for fully-qualified method name: " + methodName, ex); + } + Method externalMethod = ReflectionUtils.findMethod(declaringClass, methodNameToUse); + Assert.state(externalMethod != null && Modifier.isStatic(externalMethod.getModifiers()) && + methodReturnType.isAssignableFrom(externalMethod.getReturnType()), () -> + "No static method found named %s in %s with return type %s".formatted( + methodNameToUse, className, methodReturnType.getName())); + methods.add(externalMethod); + originalNames.remove(methodName); + } + } + + Set supportedNames = new LinkedHashSet<>(originalNames); + MethodFilter methodFilter = method -> (Modifier.isStatic(method.getModifiers()) && + supportedNames.contains(method.getName()) && + methodReturnType.isAssignableFrom(method.getReturnType())); + findMethods(methods, clazz, methodFilter); + + String methodNamesDescription = supportedNames.stream() + .map(name -> name + "()").collect(Collectors.joining(" or ")); + Assert.state(!methods.isEmpty(), () -> + "No static method found named %s in %s with return type %s".formatted( + methodNamesDescription, clazz.getName(), methodReturnType.getName())); + + long uniqueMethodNameCount = methods.stream().map(Method::getName).distinct().count(); + Assert.state(uniqueMethodNameCount == 1, () -> + "Found %d competing static methods named %s in %s with return type %s".formatted( + uniqueMethodNameCount, methodNamesDescription, clazz.getName(), methodReturnType.getName())); + + return methods.iterator().next(); + } + + private static Set findMethods(Set methods, Class clazz, MethodFilter methodFilter) { + methods.addAll(MethodIntrospector.selectMethods(clazz, methodFilter)); + if (methods.isEmpty() && TestContextAnnotationUtils.searchEnclosingClass(clazz)) { + findMethods(methods, clazz.getEnclosingClass(), methodFilter); + } + return methods; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanReflectiveProcessor.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanReflectiveProcessor.java new file mode 100644 index 000000000000..6cf45523429d --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanReflectiveProcessor.java @@ -0,0 +1,57 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import java.lang.reflect.AnnotatedElement; +import java.util.List; + +import org.springframework.aot.hint.ReflectionHints; +import org.springframework.aot.hint.TypeReference; +import org.springframework.aot.hint.annotation.ReflectiveProcessor; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.util.Assert; + +import static org.springframework.aot.hint.ExecutableMode.INVOKE; + +/** + * {@link ReflectiveProcessor} that processes {@link TestBean @TestBean} annotations. + * + * @author Sam Brannen + * @since 6.2 + */ +class TestBeanReflectiveProcessor implements ReflectiveProcessor { + + @Override + public void registerReflectionHints(ReflectionHints hints, AnnotatedElement element) { + MergedAnnotations.from(element) + .get(TestBean.class) + .synthesize(MergedAnnotation::isPresent) + .map(TestBean::methodName) + .filter(methodName -> methodName.contains("#")) + .ifPresent(methodName -> { + int indexOfHash = methodName.lastIndexOf('#'); + String className = methodName.substring(0, indexOfHash).trim(); + Assert.hasText(className, () -> "No class name present in fully-qualified method name: " + methodName); + String methodNameToUse = methodName.substring(indexOfHash + 1).trim(); + Assert.hasText(methodNameToUse, () -> "No method name present in fully-qualified method name: " + methodName); + hints.registerType(TypeReference.of(className), builder -> + builder.withMethod(methodNameToUse, List.of(), INVOKE)); + }); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/package-info.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/package-info.java new file mode 100644 index 000000000000..59256e3fe604 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/package-info.java @@ -0,0 +1,11 @@ +/** + * Bean override mechanism based on conventionally-named static methods + * in the test class. This allows defining a custom instance for the bean + * straight from the test class. + */ +@NonNullApi +@NonNullFields +package org.springframework.test.context.bean.override.convention; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/AbstractMockitoBeanOverrideHandler.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/AbstractMockitoBeanOverrideHandler.java new file mode 100644 index 000000000000..061a3bff4343 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/AbstractMockitoBeanOverrideHandler.java @@ -0,0 +1,100 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.lang.reflect.Field; + +import org.springframework.beans.factory.config.SingletonBeanRegistry; +import org.springframework.core.ResolvableType; +import org.springframework.core.style.ToStringCreator; +import org.springframework.lang.Nullable; +import org.springframework.test.context.bean.override.BeanOverrideHandler; +import org.springframework.test.context.bean.override.BeanOverrideStrategy; + +/** + * Abstract base {@link BeanOverrideHandler} implementation for Mockito. + * + * @author Phillip Webb + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + */ +abstract class AbstractMockitoBeanOverrideHandler extends BeanOverrideHandler { + + private final MockReset reset; + + + protected AbstractMockitoBeanOverrideHandler(@Nullable Field field, ResolvableType beanType, + @Nullable String beanName, BeanOverrideStrategy strategy, MockReset reset) { + + super(field, beanType, beanName, strategy); + this.reset = (reset != null ? reset : MockReset.AFTER); + } + + + /** + * Return the mock reset mode. + * @return the reset mode + */ + MockReset getReset() { + return this.reset; + } + + @Override + protected void trackOverrideInstance(Object mock, SingletonBeanRegistry trackingBeanRegistry) { + getMockBeans(trackingBeanRegistry).add(mock); + } + + private static MockBeans getMockBeans(SingletonBeanRegistry trackingBeanRegistry) { + String beanName = MockBeans.class.getName(); + MockBeans mockBeans = null; + if (trackingBeanRegistry.containsSingleton(beanName)) { + mockBeans = (MockBeans) trackingBeanRegistry.getSingleton(beanName); + } + if (mockBeans == null) { + mockBeans = new MockBeans(); + trackingBeanRegistry.registerSingleton(beanName, mockBeans); + } + return mockBeans; + } + + @Override + public boolean equals(@Nullable Object other) { + if (other == this) { + return true; + } + return (other instanceof AbstractMockitoBeanOverrideHandler that && super.equals(that) && + this.reset == that.reset); + } + + @Override + public int hashCode() { + return super.hashCode() + this.reset.hashCode(); + } + + @Override + public String toString() { + return new ToStringCreator(this) + .append("field", getField()) + .append("beanType", getBeanType()) + .append("beanName", getBeanName()) + .append("strategy", getStrategy()) + .append("reset", getReset()) + .toString(); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockBeans.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockBeans.java new file mode 100644 index 000000000000..f0b72f50ff05 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockBeans.java @@ -0,0 +1,54 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.util.ArrayList; +import java.util.List; + +import org.mockito.Mockito; + +/** + * Beans created using Mockito. + * + * @author Andy Wilkinson + * @author Sam Brannen + * @since 6.2 + */ +class MockBeans { + + private final List beans = new ArrayList<>(); + + + void add(Object bean) { + this.beans.add(bean); + } + + /** + * Reset all Mockito beans configured with the supplied {@link MockReset} strategy. + *

    No mocks will be reset if the supplied strategy is {@link MockReset#NONE}. + */ + void resetAll(MockReset reset) { + if (reset != MockReset.NONE) { + for (Object bean : this.beans) { + if (reset == MockReset.get(bean)) { + Mockito.reset(bean); + } + } + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockReset.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockReset.java new file mode 100644 index 000000000000..d4d850883c1a --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockReset.java @@ -0,0 +1,130 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.mockito.MockSettings; +import org.mockito.MockingDetails; +import org.mockito.Mockito; +import org.mockito.listeners.InvocationListener; +import org.mockito.listeners.MethodInvocationReport; +import org.mockito.mock.MockCreationSettings; + +import org.springframework.util.Assert; + +/** + * Reset strategy used on a mock bean. + * + *

    Usually applied to a mock via the {@link MockitoBean @MockitoBean} or + * {@link MockitoSpyBean @MockitoSpyBean} annotation but can also be directly + * applied to any mock in the {@code ApplicationContext} using the static methods + * in this class. + * + * @author Phillip Webb + * @author Sam Brannen + * @since 6.2 + * @see MockitoResetTestExecutionListener + */ +public enum MockReset { + + /** + * Reset the mock before the test method runs. + */ + BEFORE, + + /** + * Reset the mock after the test method runs. + */ + AFTER, + + /** + * Do not reset the mock. + */ + NONE; + + + /** + * Create {@link MockSettings settings} to be used with mocks where reset + * should occur before each test method runs. + * @return mock settings + */ + public static MockSettings before() { + return withSettings(BEFORE); + } + + /** + * Create {@link MockSettings settings} to be used with mocks where reset + * should occur after each test method runs. + * @return mock settings + */ + public static MockSettings after() { + return withSettings(AFTER); + } + + /** + * Create {@link MockSettings settings} to be used with mocks where a + * specific reset should occur. + * @param reset the reset type + * @return mock settings + */ + public static MockSettings withSettings(MockReset reset) { + return apply(reset, Mockito.withSettings()); + } + + /** + * Apply {@link MockReset} to existing {@link MockSettings settings}. + * @param reset the reset type + * @param settings the settings + * @return the configured settings + */ + public static MockSettings apply(MockReset reset, MockSettings settings) { + Assert.notNull(settings, "Settings must not be null"); + if (reset != null && reset != NONE) { + settings.invocationListeners(new ResetInvocationListener(reset)); + } + return settings; + } + + /** + * Get the {@link MockReset} strategy associated with the given mock. + * @param mock the mock + * @return the reset strategy for the given mock, or {@link MockReset#NONE} + * if no strategy is associated with the given mock + */ + static MockReset get(Object mock) { + MockingDetails mockingDetails = Mockito.mockingDetails(mock); + if (mockingDetails.isMock()) { + MockCreationSettings settings = mockingDetails.getMockCreationSettings(); + for (InvocationListener listener : settings.getInvocationListeners()) { + if (listener instanceof ResetInvocationListener resetInvocationListener) { + return resetInvocationListener.reset; + } + } + } + return MockReset.NONE; + } + + /** + * Dummy {@link InvocationListener} used to hold the {@link MockReset} value. + */ + private record ResetInvocationListener(MockReset reset) implements InvocationListener { + + @Override + public void reportInvocation(MethodInvocationReport methodInvocationReport) { + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBean.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBean.java new file mode 100644 index 000000000000..46d5c0917f9c --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBean.java @@ -0,0 +1,189 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.mockito.Answers; +import org.mockito.MockSettings; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.test.context.bean.override.BeanOverride; + +/** + * {@code @MockitoBean} is an annotation that can be used in test classes to + * override a bean in the test's + * {@link org.springframework.context.ApplicationContext ApplicationContext} + * with a Mockito mock. + * + *

    {@code @MockitoBean} can be applied in the following ways. + *

      + *
    • On a non-static field in a test class or any of its superclasses.
    • + *
    • On a non-static field in an enclosing class for a {@code @Nested} test class + * or in any class in the type hierarchy or enclosing class hierarchy above the + * {@code @Nested} test class.
    • + *
    • At the type level on a test class or any superclass or implemented interface + * in the type hierarchy above the test class.
    • + *
    • At the type level on an enclosing class for a {@code @Nested} test class + * or on any class or interface in the type hierarchy or enclosing class hierarchy + * above the {@code @Nested} test class.
    • + *
    + * + *

    When {@code @MockitoBean} is declared on a field, the bean to mock is inferred + * from the type of the annotated field. If multiple candidates exist in the + * {@code ApplicationContext}, a {@code @Qualifier} annotation can be declared + * on the field to help disambiguate. In the absence of a {@code @Qualifier} + * annotation, the name of the annotated field will be used as a fallback + * qualifier. Alternatively, you can explicitly specify a bean name to mock + * by setting the {@link #value() value} or {@link #name() name} attribute. + * + *

    When {@code @MockitoBean} is declared at the type level, the type of bean + * (or beans) to mock must be supplied via the {@link #types() types} attribute. + * If multiple candidates exist in the {@code ApplicationContext}, you can + * explicitly specify a bean name to mock by setting the {@link #name() name} + * attribute. Note, however, that the {@code types} attribute must contain a + * single type if an explicit bean {@code name} is configured. + * + *

    A bean will be created if a corresponding bean does not exist. However, if + * you would like for the test to fail when a corresponding bean does not exist, + * you can set the {@link #enforceOverride() enforceOverride} attribute to {@code true} + * — for example, {@code @MockitoBean(enforceOverride = true)}. + * + *

    Dependencies that are known to the application context but are not beans + * (such as those + * {@linkplain org.springframework.beans.factory.config.ConfigurableListableBeanFactory#registerResolvableDependency(Class, Object) + * registered directly}) will not be found, and a mocked bean will be added to + * the context alongside the existing dependency. + * + *

    NOTE: Only singleton beans can be mocked. + * Any attempt to mock a non-singleton bean will result in an exception. When + * mocking a bean created by a {@link org.springframework.beans.factory.FactoryBean + * FactoryBean}, the {@code FactoryBean} will be replaced with a singleton mock + * of the type of object created by the {@code FactoryBean}. + * + *

    There are no restrictions on the visibility of a {@code @MockitoBean} field. + * Such fields can therefore be {@code public}, {@code protected}, package-private + * (default visibility), or {@code private} depending on the needs or coding + * practices of the project. + * + *

    {@code @MockitoBean} fields and type-level {@code @MockitoBean} declarations + * will be inherited from an enclosing test class by default. See + * {@link org.springframework.test.context.NestedTestConfiguration @NestedTestConfiguration} + * for details. + * + *

    {@code @MockitoBean} may be used as a meta-annotation to create custom + * composed annotations — for example, to define common mock + * configuration in a single annotation that can be reused across a test suite. + * {@code @MockitoBean} can also be used as a {@linkplain Repeatable repeatable} + * annotation at the type level — for example, to mock several beans by + * {@link #name() name}. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + * @see org.springframework.test.context.bean.override.mockito.MockitoBeans @MockitoBeans + * @see org.springframework.test.context.bean.override.mockito.MockitoSpyBean @MockitoSpyBean + * @see org.springframework.test.context.bean.override.convention.TestBean @TestBean + */ +@Target({ElementType.FIELD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Repeatable(MockitoBeans.class) +@BeanOverride(MockitoBeanOverrideProcessor.class) +public @interface MockitoBean { + + /** + * Alias for {@link #name() name}. + *

    Intended to be used when no other attributes are needed — for + * example, {@code @MockitoBean("customBeanName")}. + * @see #name() + */ + @AliasFor("name") + String value() default ""; + + /** + * Name of the bean to mock. + *

    If left unspecified, the bean to mock is selected according to the + * configured {@link #types() types} or the annotated field's type, taking + * qualifiers into account if necessary. See the {@linkplain MockitoBean + * class-level documentation} for details. + * @see #value() + */ + @AliasFor("value") + String name() default ""; + + /** + * One or more types to mock. + *

    Defaults to none. + *

    Each type specified will result in a mock being created and registered + * with the {@code ApplicationContext}. + *

    Types must be omitted when the annotation is used on a field. + *

    When {@code @MockitoBean} also defines a {@link #name name}, this attribute + * can only contain a single value. + * @return the types to mock + * @since 6.2.2 + */ + Class[] types() default {}; + + /** + * Extra interfaces that should also be declared by the mock. + *

    Defaults to none. + * @return any extra interfaces + * @see MockSettings#extraInterfaces(Class...) + */ + Class[] extraInterfaces() default {}; + + /** + * The {@link Answers} type to use in the mock. + *

    Defaults to {@link Answers#RETURNS_DEFAULTS}. + * @return the answer type + */ + Answers answers() default Answers.RETURNS_DEFAULTS; + + /** + * Whether the generated mock is serializable. + *

    Defaults to {@code false}. + * @return {@code true} if the mock is serializable + * @see MockSettings#serializable() + */ + boolean serializable() default false; + + /** + * The reset mode to apply to the mock. + *

    The default is {@link MockReset#AFTER} meaning that mocks are + * automatically reset after each test method is invoked. + * @return the reset mode + */ + MockReset reset() default MockReset.AFTER; + + /** + * Whether to require the existence of the bean being mocked. + *

    Defaults to {@code false} which means that a mock will be created if a + * corresponding bean does not exist. + *

    Set to {@code true} to cause an exception to be thrown if a corresponding + * bean does not exist. + * @see org.springframework.test.context.bean.override.BeanOverrideStrategy#REPLACE_OR_CREATE + * @see org.springframework.test.context.bean.override.BeanOverrideStrategy#REPLACE + */ + boolean enforceOverride() default false; + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandler.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandler.java new file mode 100644 index 000000000000..449e487e88ba --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandler.java @@ -0,0 +1,171 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +import org.mockito.Answers; +import org.mockito.MockSettings; +import org.mockito.Mockito; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.core.ResolvableType; +import org.springframework.core.style.ToStringCreator; +import org.springframework.lang.Nullable; +import org.springframework.test.context.bean.override.BeanOverrideHandler; +import org.springframework.test.context.bean.override.BeanOverrideStrategy; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +import static org.springframework.test.context.bean.override.BeanOverrideStrategy.REPLACE; +import static org.springframework.test.context.bean.override.BeanOverrideStrategy.REPLACE_OR_CREATE; + +/** + * {@link BeanOverrideHandler} implementation for Mockito {@code mock} support. + * + * @author Phillip Webb + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + */ +class MockitoBeanOverrideHandler extends AbstractMockitoBeanOverrideHandler { + + private final Set> extraInterfaces; + + private final Answers answers; + + private final boolean serializable; + + + MockitoBeanOverrideHandler(ResolvableType typeToMock, MockitoBean mockitoBean) { + this(null, typeToMock, mockitoBean); + } + + MockitoBeanOverrideHandler(@Nullable Field field, ResolvableType typeToMock, MockitoBean mockitoBean) { + this(field, typeToMock, (!mockitoBean.name().isBlank() ? mockitoBean.name() : null), + (mockitoBean.enforceOverride() ? REPLACE : REPLACE_OR_CREATE), + mockitoBean.reset(), mockitoBean.extraInterfaces(), mockitoBean.answers(), mockitoBean.serializable()); + } + + private MockitoBeanOverrideHandler(@Nullable Field field, ResolvableType typeToMock, @Nullable String beanName, + BeanOverrideStrategy strategy, MockReset reset, Class[] extraInterfaces, Answers answers, + boolean serializable) { + + super(field, typeToMock, beanName, strategy, reset); + Assert.notNull(typeToMock, "'typeToMock' must not be null"); + this.extraInterfaces = asClassSet(extraInterfaces); + this.answers = answers; + this.serializable = serializable; + } + + + private static Set> asClassSet(Class[] classes) { + if (classes.length == 0) { + return Collections.emptySet(); + } + Set> classSet = new LinkedHashSet<>(Arrays.asList(classes)); + return Collections.unmodifiableSet(classSet); + } + + + /** + * Return the extra interfaces. + * @return the extra interfaces or an empty set + */ + Set> getExtraInterfaces() { + return this.extraInterfaces; + } + + /** + * Return the {@link Answers}. + * @return the answers mode + */ + Answers getAnswers() { + return this.answers; + } + + /** + * Determine if the mock is serializable. + * @return {@code true} if the mock is serializable + */ + boolean isSerializable() { + return this.serializable; + } + + @Override + protected Object createOverrideInstance(String beanName, + @Nullable BeanDefinition existingBeanDefinition, @Nullable Object existingBeanInstance) { + + return createMock(beanName); + } + + @SuppressWarnings("unchecked") + private T createMock(String name) { + MockSettings settings = MockReset.withSettings(getReset()); + if (StringUtils.hasLength(name)) { + settings.name(name); + } + if (!this.extraInterfaces.isEmpty()) { + settings.extraInterfaces(ClassUtils.toClassArray(this.extraInterfaces)); + } + settings.defaultAnswer(this.answers); + if (this.serializable) { + settings.serializable(); + } + Class targetType = getBeanType().resolve(); + return (T) Mockito.mock(targetType, settings); + } + + @Override + public boolean equals(@Nullable Object other) { + if (other == this) { + return true; + } + if (other == null || other.getClass() != getClass()) { + return false; + } + return (other instanceof MockitoBeanOverrideHandler that && super.equals(that) && + (this.serializable == that.serializable) && (this.answers == that.answers) && + Objects.equals(this.extraInterfaces, that.extraInterfaces)); + } + + @Override + public int hashCode() { + return super.hashCode() + Objects.hash(this.extraInterfaces, this.answers, this.serializable); + } + + @Override + public String toString() { + return new ToStringCreator(this) + .append("field", getField()) + .append("beanType", getBeanType()) + .append("beanName", getBeanName()) + .append("strategy", getStrategy()) + .append("reset", getReset()) + .append("extraInterfaces", getExtraInterfaces()) + .append("answers", getAnswers()) + .append("serializable", isSerializable()) + .toString(); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessor.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessor.java new file mode 100644 index 000000000000..7756dd720bdb --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessor.java @@ -0,0 +1,91 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.core.ResolvableType; +import org.springframework.test.context.bean.override.BeanOverrideHandler; +import org.springframework.test.context.bean.override.BeanOverrideProcessor; +import org.springframework.util.Assert; + +/** + * {@link BeanOverrideProcessor} implementation that provides support for + * {@link MockitoBean @MockitoBean} and {@link MockitoSpyBean @MockitoSpyBean}. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + * @see MockitoBean @MockitoBean + * @see MockitoSpyBean @MockitoSpyBean + */ +class MockitoBeanOverrideProcessor implements BeanOverrideProcessor { + + @Override + public AbstractMockitoBeanOverrideHandler createHandler(Annotation overrideAnnotation, Class testClass, Field field) { + if (overrideAnnotation instanceof MockitoBean mockitoBean) { + Assert.state(mockitoBean.types().length == 0, + "The @MockitoBean 'types' attribute must be omitted when declared on a field"); + return new MockitoBeanOverrideHandler(field, ResolvableType.forField(field, testClass), mockitoBean); + } + else if (overrideAnnotation instanceof MockitoSpyBean mockitoSpyBean) { + Assert.state(mockitoSpyBean.types().length == 0, + "The @MockitoSpyBean 'types' attribute must be omitted when declared on a field"); + return new MockitoSpyBeanOverrideHandler(field, ResolvableType.forField(field, testClass), mockitoSpyBean); + } + throw new IllegalStateException(""" + Invalid annotation passed to MockitoBeanOverrideProcessor: \ + expected either @MockitoBean or @MockitoSpyBean on field %s.%s""" + .formatted(field.getDeclaringClass().getName(), field.getName())); + } + + @Override + public List createHandlers(Annotation overrideAnnotation, Class testClass) { + if (overrideAnnotation instanceof MockitoBean mockitoBean) { + Class[] types = mockitoBean.types(); + Assert.state(types.length > 0, + "The @MockitoBean 'types' attribute must not be empty when declared on a class"); + Assert.state(mockitoBean.name().isEmpty() || types.length == 1, + "The @MockitoBean 'name' attribute cannot be used when mocking multiple types"); + List handlers = new ArrayList<>(); + for (Class type : types) { + handlers.add(new MockitoBeanOverrideHandler(ResolvableType.forClass(type), mockitoBean)); + } + return handlers; + } + else if (overrideAnnotation instanceof MockitoSpyBean mockitoSpyBean) { + Class[] types = mockitoSpyBean.types(); + Assert.state(types.length > 0, + "The @MockitoSpyBean 'types' attribute must not be empty when declared on a class"); + Assert.state(mockitoSpyBean.name().isEmpty() || types.length == 1, + "The @MockitoSpyBean 'name' attribute cannot be used when mocking multiple types"); + List handlers = new ArrayList<>(); + for (Class type : types) { + handlers.add(new MockitoSpyBeanOverrideHandler(ResolvableType.forClass(type), mockitoSpyBean)); + } + return handlers; + } + throw new IllegalStateException(""" + Invalid annotation passed to MockitoBeanOverrideProcessor: \ + expected either @MockitoBean or @MockitoSpyBean on test class %s""" + .formatted(testClass.getName())); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeans.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeans.java new file mode 100644 index 000000000000..3bf80b47d13b --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeans.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Container for {@link MockitoBean @MockitoBean} annotations which allows + * {@code @MockitoBean} to be used as a {@linkplain java.lang.annotation.Repeatable + * repeatable annotation} at the type level — for example, on test classes + * or interfaces implemented by test classes. + * + * @author Sam Brannen + * @since 6.2.2 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MockitoBeans { + + MockitoBean[] value(); + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoResetTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoResetTestExecutionListener.java new file mode 100644 index 000000000000..c704da0e79a7 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoResetTestExecutionListener.java @@ -0,0 +1,194 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.mockito.Mockito; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.Ordered; +import org.springframework.lang.Nullable; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.support.AbstractTestExecutionListener; +import org.springframework.util.ClassUtils; + +/** + * {@code TestExecutionListener} that resets any mock beans that have been marked + * with a {@link MockReset}. + * + * @author Phillip Webb + * @author Sam Brannen + * @since 6.2 + * @see MockitoBean @MockitoBean + * @see MockitoSpyBean @MockitoSpyBean + */ +public class MockitoResetTestExecutionListener extends AbstractTestExecutionListener { + + /** + * The {@link #getOrder() order} value for this listener + * ({@code Ordered.LOWEST_PRECEDENCE - 100}): {@value}. + * @since 6.2.3 + */ + public static final int ORDER = Ordered.LOWEST_PRECEDENCE - 100; + + private static final Log logger = LogFactory.getLog(MockitoResetTestExecutionListener.class); + + /** + * Boolean flag which tracks whether Mockito is present in the classpath. + * @see #mockitoInitialized + * @see #isEnabled() + */ + private static final boolean mockitoPresent = ClassUtils.isPresent("org.mockito.Mockito", + MockitoResetTestExecutionListener.class.getClassLoader()); + + /** + * Boolean flag which tracks whether Mockito has been successfully initialized + * in the current environment. + *

    Even if {@link #mockitoPresent} evaluates to {@code true}, this flag + * may eventually evaluate to {@code false} — for example, in a GraalVM + * native image if the necessary reachability metadata has not been registered + * for the {@link org.mockito.plugins.MockMaker} in use. + * @see #mockitoPresent + * @see #isEnabled() + */ + @Nullable + private static volatile Boolean mockitoInitialized; + + + /** + * Returns {@value #ORDER}, which ensures that the + * {@code MockitoResetTestExecutionListener} is ordered after all standard + * {@code TestExecutionListener} implementations. + * @see #ORDER + */ + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE - 100; + } + + @Override + public void beforeTestMethod(TestContext testContext) { + if (isEnabled()) { + resetMocks(testContext.getApplicationContext(), MockReset.BEFORE); + } + } + + @Override + public void afterTestMethod(TestContext testContext) { + if (isEnabled()) { + resetMocks(testContext.getApplicationContext(), MockReset.AFTER); + } + } + + + private static void resetMocks(ApplicationContext applicationContext, MockReset reset) { + if (applicationContext instanceof ConfigurableApplicationContext configurableContext) { + resetMocks(configurableContext, reset); + } + } + + private static void resetMocks(ConfigurableApplicationContext applicationContext, MockReset reset) { + ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory(); + String[] beanNames = beanFactory.getBeanDefinitionNames(); + Set instantiatedSingletons = new HashSet<>(Arrays.asList(beanFactory.getSingletonNames())); + for (String beanName : beanNames) { + BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName); + if (beanDefinition.isSingleton() && instantiatedSingletons.contains(beanName)) { + Object bean = getBean(beanFactory, beanName); + if (bean != null && reset == MockReset.get(bean)) { + Mockito.reset(bean); + } + } + } + try { + beanFactory.getBean(MockBeans.class).resetAll(reset); + } + catch (NoSuchBeanDefinitionException ex) { + // Continue + } + if (applicationContext.getParent() != null) { + resetMocks(applicationContext.getParent(), reset); + } + } + + @Nullable + private static Object getBean(ConfigurableListableBeanFactory beanFactory, String beanName) { + try { + if (isStandardBeanOrSingletonFactoryBean(beanFactory, beanName)) { + return beanFactory.getBean(beanName); + } + } + catch (Exception ex) { + // Continue + } + return beanFactory.getSingleton(beanName); + } + + private static boolean isStandardBeanOrSingletonFactoryBean(BeanFactory beanFactory, String beanName) { + String factoryBeanName = BeanFactory.FACTORY_BEAN_PREFIX + beanName; + if (beanFactory.containsBean(factoryBeanName)) { + FactoryBean factoryBean = (FactoryBean) beanFactory.getBean(factoryBeanName); + return factoryBean.isSingleton(); + } + return true; + } + + /** + * Determine if this listener is enabled in the current environment. + * @see #mockitoPresent + * @see #mockitoInitialized + */ + private static boolean isEnabled() { + if (!mockitoPresent) { + return false; + } + Boolean enabled = mockitoInitialized; + if (enabled == null) { + try { + // Invoke isMock() on a non-null object to initialize core Mockito classes + // in order to reliably determine if this listener is "enabled" both on the + // JVM as well as within a GraalVM native image. + Mockito.mockingDetails("a string is not a mock").isMock(); + + // If we got this far, we assume Mockito is usable in the current environment. + enabled = true; + } + catch (Throwable ex) { + enabled = false; + if (logger.isDebugEnabled()) { + logger.debug(""" + MockitoResetTestExecutionListener is disabled in the current environment. \ + See exception for details.""", ex); + } + } + mockitoInitialized = enabled; + } + return enabled; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBean.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBean.java new file mode 100644 index 000000000000..e42c0b4563ba --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBean.java @@ -0,0 +1,147 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.test.context.bean.override.BeanOverride; + +/** + * {@code @MockitoSpyBean} is an annotation that can be used in test classes to + * override a bean in the test's + * {@link org.springframework.context.ApplicationContext ApplicationContext} + * with a Mockito spy that wraps the original bean instance. + * + *

    {@code @MockitoSpyBean} can be applied in the following ways. + *

      + *
    • On a non-static field in a test class or any of its superclasses.
    • + *
    • On a non-static field in an enclosing class for a {@code @Nested} test class + * or in any class in the type hierarchy or enclosing class hierarchy above the + * {@code @Nested} test class.
    • + *
    • At the type level on a test class or any superclass or implemented interface + * in the type hierarchy above the test class.
    • + *
    • At the type level on an enclosing class for a {@code @Nested} test class + * or on any class or interface in the type hierarchy or enclosing class hierarchy + * above the {@code @Nested} test class.
    • + *
    + * + *

    When {@code @MockitoSpyBean} is declared on a field, the bean to spy is + * inferred from the type of the annotated field. If multiple candidates exist in + * the {@code ApplicationContext}, a {@code @Qualifier} annotation can be declared + * on the field to help disambiguate. In the absence of a {@code @Qualifier} + * annotation, the name of the annotated field will be used as a fallback + * qualifier. Alternatively, you can explicitly specify a bean name to spy + * by setting the {@link #value() value} or {@link #name() name} attribute. If a + * bean name is specified, it is required that a target bean with that name has + * been previously registered in the application context. + * + *

    When {@code @MockitoSpyBean} is declared at the type level, the type of bean + * (or beans) to spy must be supplied via the {@link #types() types} attribute. + * If multiple candidates exist in the {@code ApplicationContext}, you can + * explicitly specify a bean name to spy by setting the {@link #name() name} + * attribute. Note, however, that the {@code types} attribute must contain a + * single type if an explicit bean {@code name} is configured. + * + *

    A spy cannot be created for components which are known to the application + * context but are not beans — for example, components + * {@link org.springframework.beans.factory.config.ConfigurableListableBeanFactory#registerResolvableDependency(Class, Object) + * registered directly} as resolvable dependencies. + * + *

    NOTE: Only singleton beans can be spied. Any attempt + * to create a spy for a non-singleton bean will result in an exception. When + * creating a spy for a {@link org.springframework.beans.factory.FactoryBean FactoryBean}, + * a spy will be created for the object created by the {@code FactoryBean}, not + * for the {@code FactoryBean} itself. + * + *

    There are no restrictions on the visibility of a {@code @MockitoSpyBean} field. + * Such fields can therefore be {@code public}, {@code protected}, package-private + * (default visibility), or {@code private} depending on the needs or coding + * practices of the project. + * + *

    {@code @MockitoSpyBean} fields and type-level {@code @MockitoSpyBean} declarations + * will be inherited from an enclosing test class by default. See + * {@link org.springframework.test.context.NestedTestConfiguration @NestedTestConfiguration} + * for details. + * + *

    {@code @MockitoSpyBean} may be used as a meta-annotation to create + * custom composed annotations — for example, to define common spy + * configuration in a single annotation that can be reused across a test suite. + * {@code @MockitoSpyBean} can also be used as a {@linkplain Repeatable repeatable} + * annotation at the type level — for example, to spy on several beans by + * {@link #name() name}. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + * @see org.springframework.test.context.bean.override.mockito.MockitoBean @MockitoBean + * @see org.springframework.test.context.bean.override.convention.TestBean @TestBean + */ +@Target({ElementType.FIELD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Repeatable(MockitoSpyBeans.class) +@BeanOverride(MockitoBeanOverrideProcessor.class) +public @interface MockitoSpyBean { + + /** + * Alias for {@link #name() name}. + *

    Intended to be used when no other attributes are needed — for + * example, {@code @MockitoSpyBean("customBeanName")}. + * @see #name() + */ + @AliasFor("name") + String value() default ""; + + /** + * Name of the bean to spy. + *

    If left unspecified, the bean to spy is selected according to the + * configured {@link #types() types} or the annotated field's type, taking + * qualifiers into account if necessary. See the {@linkplain MockitoSpyBean + * class-level documentation} for details. + * @see #value() + */ + @AliasFor("value") + String name() default ""; + + /** + * One or more types to spy. + *

    Defaults to none. + *

    Each type specified will result in a spy being created and registered + * with the {@code ApplicationContext}. + *

    Types must be omitted when the annotation is used on a field. + *

    When {@code @MockitoSpyBean} also defines a {@link #name name}, this + * attribute can only contain a single value. + * @return the types to spy + * @since 6.2.3 + */ + Class[] types() default {}; + + /** + * The reset mode to apply to the spied bean. + *

    The default is {@link MockReset#AFTER} meaning that spies are automatically + * reset after each test method is invoked. + * @return the reset mode + */ + MockReset reset() default MockReset.AFTER; + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandler.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandler.java new file mode 100644 index 000000000000..ce3f11cbe204 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandler.java @@ -0,0 +1,113 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; + +import org.mockito.AdditionalAnswers; +import org.mockito.MockSettings; +import org.mockito.Mockito; +import org.mockito.listeners.VerificationStartedEvent; +import org.mockito.listeners.VerificationStartedListener; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.core.ResolvableType; +import org.springframework.lang.Nullable; +import org.springframework.test.context.bean.override.BeanOverrideHandler; +import org.springframework.test.context.bean.override.BeanOverrideStrategy; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * {@link BeanOverrideHandler} implementation for Mockito {@code spy} support. + * + * @author Phillip Webb + * @author Simon Baslé + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + */ +class MockitoSpyBeanOverrideHandler extends AbstractMockitoBeanOverrideHandler { + + private static final VerificationStartedListener verificationStartedListener = + new SpringAopBypassingVerificationStartedListener(); + + + MockitoSpyBeanOverrideHandler(ResolvableType typeToSpy, MockitoSpyBean spyBean) { + this(null, typeToSpy, spyBean); + } + + MockitoSpyBeanOverrideHandler(@Nullable Field field, ResolvableType typeToSpy, MockitoSpyBean spyBean) { + super(field, typeToSpy, (StringUtils.hasText(spyBean.name()) ? spyBean.name() : null), + BeanOverrideStrategy.WRAP, spyBean.reset()); + Assert.notNull(typeToSpy, "typeToSpy must not be null"); + } + + + @Override + protected Object createOverrideInstance(String beanName, @Nullable BeanDefinition existingBeanDefinition, + @Nullable Object existingBeanInstance) { + + Assert.notNull(existingBeanInstance, + () -> "@MockitoSpyBean requires an existing bean instance for bean " + beanName); + return createSpy(beanName, existingBeanInstance); + } + + private Object createSpy(String name, Object instance) { + Class resolvedTypeToOverride = getBeanType().resolve(); + Assert.notNull(resolvedTypeToOverride, "Failed to resolve type to override"); + Assert.isInstanceOf(resolvedTypeToOverride, instance); + if (Mockito.mockingDetails(instance).isSpy()) { + return instance; + } + + MockSettings settings = MockReset.withSettings(getReset()); + if (StringUtils.hasLength(name)) { + settings.name(name); + } + if (SpringMockResolver.springAopPresent) { + settings.verificationStartedListeners(verificationStartedListener); + } + + Class toSpy; + if (Proxy.isProxyClass(instance.getClass())) { + settings.defaultAnswer(AdditionalAnswers.delegatesTo(instance)); + toSpy = getBeanType().toClass(); + } + else { + settings.defaultAnswer(Mockito.CALLS_REAL_METHODS); + settings.spiedInstance(instance); + toSpy = instance.getClass(); + } + return Mockito.mock(toSpy, settings); + } + + + /** + * A {@link VerificationStartedListener} that bypasses any proxy created by + * Spring AOP when the verification of a spy starts. + */ + private static final class SpringAopBypassingVerificationStartedListener implements VerificationStartedListener { + + @Override + public void onVerificationStarted(VerificationStartedEvent event) { + event.setMock(SpringMockResolver.getUltimateTargetObject(event.getMock())); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeans.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeans.java new file mode 100644 index 000000000000..2482b96f2477 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeans.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Container for {@link MockitoSpyBean @MockitoSpyBean} annotations which allows + * {@code @MockitoSpyBean} to be used as a {@linkplain java.lang.annotation.Repeatable + * repeatable annotation} at the type level — for example, on test classes + * or interfaces implemented by test classes. + * + * @author Sam Brannen + * @since 6.2.3 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MockitoSpyBeans { + + MockitoSpyBean[] value(); + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/SpringMockResolver.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/SpringMockResolver.java new file mode 100644 index 000000000000..7671e7baca7e --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/SpringMockResolver.java @@ -0,0 +1,82 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.mockito.plugins.MockResolver; + +import org.springframework.aop.TargetSource; +import org.springframework.aop.framework.Advised; +import org.springframework.aop.support.AopUtils; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * A {@link MockResolver} for testing Spring applications with Mockito. + * + *

    Resolves mocks by walking the Spring AOP proxy chain until the target or a + * non-static proxy is found. + * + * @author Sam Brannen + * @author Andy Wilkinson + * @author Juergen Hoeller + * @since 6.2 + */ +public class SpringMockResolver implements MockResolver { + + static final boolean springAopPresent = ClassUtils.isPresent( + "org.springframework.aop.framework.Advised", SpringMockResolver.class.getClassLoader()); + + + @Override + public Object resolve(Object instance) { + if (springAopPresent) { + return getUltimateTargetObject(instance); + } + return instance; + } + + /** + * This is a modified version of + * {@link org.springframework.test.util.AopTestUtils#getUltimateTargetObject(Object) + * AopTestUtils#getUltimateTargetObject()} which only checks static target sources. + * @param candidate the instance to check (potentially a Spring AOP proxy; + * never {@code null}) + * @return the target object or the {@code candidate} (never {@code null}) + * @throws IllegalStateException if an error occurs while unwrapping a proxy + * @see Advised#getTargetSource() + * @see TargetSource#isStatic() + */ + static Object getUltimateTargetObject(Object candidate) { + Assert.notNull(candidate, "Candidate must not be null"); + try { + if (AopUtils.isAopProxy(candidate) && candidate instanceof Advised advised) { + TargetSource targetSource = advised.getTargetSource(); + if (targetSource.isStatic()) { + Object target = targetSource.getTarget(); + if (target != null) { + return getUltimateTargetObject(target); + } + } + } + } + catch (Throwable ex) { + throw new IllegalStateException("Failed to unwrap proxied object", ex); + } + return candidate; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/package-info.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/package-info.java new file mode 100644 index 000000000000..15330b2b514a --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/package-info.java @@ -0,0 +1,9 @@ +/** + * Bean overriding mechanism based on Mockito mocking and spying. + */ +@NonNullApi +@NonNullFields +package org.springframework.test.context.bean.override.mockito; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/package-info.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/package-info.java new file mode 100644 index 000000000000..4969d011ca97 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/package-info.java @@ -0,0 +1,9 @@ +/** + * Support case-by-case Bean overriding in Spring tests. + */ +@NonNullApi +@NonNullFields +package org.springframework.test.context.bean.override; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java b/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java index d63715d04cb8..7d64249ce654 100644 --- a/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java +++ b/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java @@ -111,7 +111,7 @@ public interface ContextCache { * {@linkplain org.springframework.context.ConfigurableApplicationContext#close() close} * it if it is an instance of {@code ConfigurableApplicationContext}. *

    Generally speaking, this method should be called to properly evict - * a context from the cache (e.g., due to a custom eviction policy) or if + * a context from the cache (for example, due to a custom eviction policy) or if * the state of a singleton bean has been modified, potentially affecting * future interaction with the context. *

    In addition, the semantics of the supplied {@code HierarchyMode} must diff --git a/spring-test/src/main/java/org/springframework/test/context/event/ApplicationEventsTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/event/ApplicationEventsTestExecutionListener.java index c2362297e0a6..4e890e661fd5 100644 --- a/spring-test/src/main/java/org/springframework/test/context/event/ApplicationEventsTestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/event/ApplicationEventsTestExecutionListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2025 the original author 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,6 +48,12 @@ */ public class ApplicationEventsTestExecutionListener extends AbstractTestExecutionListener { + /** + * The {@link #getOrder() order} value for this listener: {@value}. + * @since 6.2.3 + */ + public static final int ORDER = 1800; + /** * Attribute name for a {@link TestContext} attribute which indicates * whether the test class for the given test context is annotated with @@ -61,11 +67,18 @@ public class ApplicationEventsTestExecutionListener extends AbstractTestExecutio /** - * Returns {@code 1800}. + * Returns {@value #ORDER}, which ensures that the {@code ApplicationEventsTestExecutionListener} + * is ordered after the + * {@link org.springframework.test.context.support.DirtiesContextBeforeModesTestExecutionListener + * DirtiesContextBeforeModesTestExecutionListener} and before the + * {@link org.springframework.test.context.bean.override.BeanOverrideTestExecutionListener + * BeanOverrideTestExecutionListener} and the + * {@link org.springframework.test.context.support.DependencyInjectionTestExecutionListener + * DependencyInjectionTestExecutionListener}. */ @Override public final int getOrder() { - return 1800; + return ORDER; } @Override diff --git a/spring-test/src/main/java/org/springframework/test/context/event/EventPublishingTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/event/EventPublishingTestExecutionListener.java index 953b5710ce89..798874532094 100644 --- a/spring-test/src/main/java/org/springframework/test/context/event/EventPublishingTestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/event/EventPublishingTestExecutionListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 the original author 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,11 +98,22 @@ public class EventPublishingTestExecutionListener extends AbstractTestExecutionListener { /** - * Returns {@code 10000}. + * The {@link #getOrder() order} value for this listener: {@value}. + * @since 6.2.3 + */ + public static final int ORDER = 10_000; + + /** + * Returns {@value #ORDER}, which ensures that the {@code EventPublishingTestExecutionListener} + * is ordered after the + * {@link org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener + * SqlScriptsTestExecutionListener} and before the + * {@link org.springframework.test.context.bean.override.mockito.MockitoResetTestExecutionListener + * MockitoResetTestExecutionListener}. */ @Override public final int getOrder() { - return 10_000; + return ORDER; } /** diff --git a/spring-test/src/main/java/org/springframework/test/context/event/RecordApplicationEvents.java b/spring-test/src/main/java/org/springframework/test/context/event/RecordApplicationEvents.java index 5f74660a5d6e..b4db02f945cd 100644 --- a/spring-test/src/main/java/org/springframework/test/context/event/RecordApplicationEvents.java +++ b/spring-test/src/main/java/org/springframework/test/context/event/RecordApplicationEvents.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 @@ import java.lang.annotation.Target; /** - * {@code @RecordApplicationEvents} is a class-level annotation that is used to - * instruct the Spring TestContext Framework to record all + * {@code @RecordApplicationEvents} is an annotation that can be applied to a test + * class to instruct the Spring TestContext Framework to record all * {@linkplain org.springframework.context.ApplicationEvent application events} * that are published in the {@link org.springframework.context.ApplicationContext * ApplicationContext} during the execution of a single test, either from the diff --git a/spring-test/src/main/java/org/springframework/test/context/jdbc/Sql.java b/spring-test/src/main/java/org/springframework/test/context/jdbc/Sql.java index 5b4642637ddb..e19cb750e8de 100644 --- a/spring-test/src/main/java/org/springframework/test/context/jdbc/Sql.java +++ b/spring-test/src/main/java/org/springframework/test/context/jdbc/Sql.java @@ -111,10 +111,14 @@ * test class is defined. A path starting with a slash will be treated as an * absolute classpath resource, for example: * {@code "/org/example/schema.sql"}. A path which references a - * URL (e.g., a path prefixed with + * URL (for example, a path prefixed with * {@link org.springframework.util.ResourceUtils#CLASSPATH_URL_PREFIX classpath:}, * {@link org.springframework.util.ResourceUtils#FILE_URL_PREFIX file:}, * {@code http:}, etc.) will be loaded using the specified resource protocol. + *

    As of Spring Framework 6.2, paths may contain property placeholders + * (${...}) that will be replaced by properties stored in the + * {@link org.springframework.core.env.Environment Environment} of the test's + * {@code ApplicationContext}. *

    Default Script Detection

    *

    If no SQL scripts or {@link #statements} are specified, an attempt will * be made to detect a default script depending on where this @@ -131,6 +135,7 @@ * * @see #value * @see #statements + * @see org.springframework.core.env.Environment#resolveRequiredPlaceholders(String) */ @AliasFor("value") String[] scripts() default {}; diff --git a/spring-test/src/main/java/org/springframework/test/context/jdbc/SqlScriptsTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/jdbc/SqlScriptsTestExecutionListener.java index f219bea1f744..1a8cb23bf06c 100644 --- a/spring-test/src/main/java/org/springframework/test/context/jdbc/SqlScriptsTestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/jdbc/SqlScriptsTestExecutionListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -117,6 +117,12 @@ */ public class SqlScriptsTestExecutionListener extends AbstractTestExecutionListener implements AotTestExecutionListener { + /** + * The {@link #getOrder() order} value for this listener: {@value}. + * @since 6.2.3 + */ + public static final int ORDER = 5000; + private static final String SLASH = "/"; private static final Log logger = LogFactory.getLog(SqlScriptsTestExecutionListener.class); @@ -126,11 +132,16 @@ public class SqlScriptsTestExecutionListener extends AbstractTestExecutionListen /** - * Returns {@code 5000}. + * Returns {@value #ORDER}, which ensures that the {@code SqlScriptsTestExecutionListener} + * is ordered after the + * {@link org.springframework.test.context.transaction.TransactionalTestExecutionListener + * TransactionalTestExecutionListener} and before the + * {@link org.springframework.test.context.event.EventPublishingTestExecutionListener + * EventPublishingTestExecutionListener}. */ @Override public final int getOrder() { - return 5000; + return ORDER; } /** @@ -182,10 +193,10 @@ public void afterTestMethod(TestContext testContext) { @Override public void processAheadOfTime(RuntimeHints runtimeHints, Class testClass, ClassLoader classLoader) { getSqlAnnotationsFor(testClass).forEach(sql -> - registerClasspathResources(getScripts(sql, testClass, null, true), runtimeHints, classLoader)); + registerClasspathResources(getScripts(sql, testClass, null, true), runtimeHints, classLoader)); getSqlMethods(testClass).forEach(testMethod -> - getSqlAnnotationsFor(testMethod).forEach(sql -> - registerClasspathResources(getScripts(sql, testClass, testMethod, false), runtimeHints, classLoader))); + getSqlAnnotationsFor(testMethod).forEach(sql -> + registerClasspathResources(getScripts(sql, testClass, testMethod, false), runtimeHints, classLoader))); } /** @@ -308,8 +319,9 @@ else if (logger.isDebugEnabled()) { Method testMethod = (methodLevel ? testContext.getTestMethod() : null); String[] scripts = getScripts(sql, testContext.getTestClass(), testMethod, classLevel); + ApplicationContext applicationContext = testContext.getApplicationContext(); List scriptResources = TestContextResourceUtils.convertToResourceList( - testContext.getApplicationContext(), scripts); + applicationContext, applicationContext.getEnvironment(), scripts); for (String stmt : sql.statements()) { if (StringUtils.hasText(stmt)) { stmt = stmt.trim(); @@ -412,6 +424,7 @@ private String[] getScripts(Sql sql, Class testClass, @Nullable Method testMe * Detect a default SQL script by implementing the algorithm defined in * {@link Sql#scripts}. */ + @SuppressWarnings("NullAway") private String detectDefaultScript(Class testClass, @Nullable Method testMethod, boolean classLevel) { Assert.state(classLevel || testMethod != null, "Method-level @Sql requires a testMethod"); 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 58ed0b9f4de0..c93f477b5555 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 @@ -90,8 +90,7 @@ protected ConditionEvaluationResult evaluateAnnotation(Cl Function expressionExtractor, Function reasonExtractor, Function loadContextExtractor, boolean enabledOnTrue, ExtensionContext context) { - Assert.state(context.getElement().isPresent(), "No AnnotatedElement"); - AnnotatedElement element = context.getElement().get(); + AnnotatedElement element = context.getElement().orElseThrow(() -> new IllegalStateException("No AnnotatedElement")); Optional annotation = findMergedAnnotation(element, annotationType); if (annotation.isEmpty()) { @@ -153,8 +152,7 @@ protected ConditionEvaluationResult evaluateAnnotation(Cl private boolean evaluateExpression(String expression, boolean loadContext, Class annotationType, ExtensionContext context) { - Assert.state(context.getElement().isPresent(), "No AnnotatedElement"); - AnnotatedElement element = context.getElement().get(); + AnnotatedElement element = context.getElement().orElseThrow(() -> new IllegalStateException("No AnnotatedElement")); GenericApplicationContext gac = null; ApplicationContext applicationContext; 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 74477bfe1373..5e66aa58f4c8 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-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +20,6 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Executable; import java.lang.reflect.Method; -import java.lang.reflect.Modifier; import java.lang.reflect.Parameter; import java.util.Arrays; import java.util.List; @@ -118,9 +117,7 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes List.of(BeforeAll.class, AfterAll.class, BeforeEach.class, AfterEach.class, Testable.class); private static final MethodFilter autowiredTestOrLifecycleMethodFilter = - ReflectionUtils.USER_DECLARED_METHODS - .and(method -> !Modifier.isPrivate(method.getModifiers())) - .and(SpringExtension::isAutowiredTestOrLifecycleMethod); + ReflectionUtils.USER_DECLARED_METHODS.and(SpringExtension::isAutowiredTestOrLifecycleMethod); /** @@ -377,6 +374,7 @@ private static Store getStore(ExtensionContext context) { * the supplied {@link TestContextManager}. * @since 6.1 */ + @SuppressWarnings("NullAway") private static void registerMethodInvoker(TestContextManager testContextManager, ExtensionContext context) { testContextManager.getTestContext().setMethodInvoker(context.getExecutableInvoker()::invoke); } diff --git a/spring-test/src/main/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListener.java index 18bf72b67214..8178d825c432 100644 --- a/spring-test/src/main/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,12 @@ */ class MicrometerObservationRegistryTestExecutionListener extends AbstractTestExecutionListener { + /** + * The {@link #getOrder() order} value for this listener: {@value}. + * @since 6.2.3 + */ + public static final int ORDER = 2500; + private static final Log logger = LogFactory.getLog(MicrometerObservationRegistryTestExecutionListener.class); /** @@ -107,11 +113,16 @@ public MicrometerObservationRegistryTestExecutionListener() { /** - * Returns {@code 2500}. + * Returns {@value #ORDER}, which ensures that the + * {@code MicrometerObservationRegistryTestExecutionListener} is ordered after the + * {@link org.springframework.test.context.support.DependencyInjectionTestExecutionListener + * DependencyInjectionTestExecutionListener} and before the + * {@link org.springframework.test.context.support.DirtiesContextTestExecutionListener + * DirtiesContextTestExecutionListener}. */ @Override public final int getOrder() { - return 2500; + return ORDER; } /** diff --git a/spring-test/src/main/java/org/springframework/test/context/support/AbstractDelegatingSmartContextLoader.java b/spring-test/src/main/java/org/springframework/test/context/support/AbstractDelegatingSmartContextLoader.java index 05f9126164ac..4cc99d69a427 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/AbstractDelegatingSmartContextLoader.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/AbstractDelegatingSmartContextLoader.java @@ -49,7 +49,7 @@ * supports both XML configuration files and Groovy scripts simultaneously. * *

    Placing an empty {@code @ContextConfiguration} annotation on a test class signals - * that default resource locations (e.g., XML configuration files or Groovy scripts) + * that default resource locations (for example, XML configuration files or Groovy scripts) * or default * {@linkplain org.springframework.context.annotation.Configuration configuration classes} * should be detected. Furthermore, if a specific {@link ContextLoader} or @@ -57,7 +57,7 @@ * {@code @ContextConfiguration}, a concrete subclass of * {@code AbstractDelegatingSmartContextLoader} will be used as the default loader, * thus providing automatic support for either path-based resource locations - * (e.g., XML configuration files and Groovy scripts) or annotated classes, + * (for example, XML configuration files and Groovy scripts) or annotated classes, * but not both simultaneously. * *

    A test class may optionally declare neither path-based resource locations diff --git a/spring-test/src/main/java/org/springframework/test/context/support/AbstractDirtiesContextTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/support/AbstractDirtiesContextTestExecutionListener.java index 7697565cfd19..084d6ae7fffc 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/AbstractDirtiesContextTestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/AbstractDirtiesContextTestExecutionListener.java @@ -84,6 +84,7 @@ protected void dirtyContext(TestContext testContext, @Nullable HierarchyMode hie * @since 4.2 * @see #dirtyContext */ + @SuppressWarnings("NullAway") protected void beforeOrAfterTestMethod(TestContext testContext, MethodMode requiredMethodMode, ClassMode requiredClassMode) throws Exception { @@ -135,6 +136,7 @@ else if (logger.isDebugEnabled()) { * @since 4.2 * @see #dirtyContext */ + @SuppressWarnings("NullAway") protected void beforeOrAfterTestClass(TestContext testContext, ClassMode requiredClassMode) throws Exception { Assert.notNull(testContext, "TestContext must not be null"); Assert.notNull(requiredClassMode, "requiredClassMode must not be null"); diff --git a/spring-test/src/main/java/org/springframework/test/context/support/AbstractTestContextBootstrapper.java b/spring-test/src/main/java/org/springframework/test/context/support/AbstractTestContextBootstrapper.java index 9da77a8a70ba..df48cd626f2e 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/AbstractTestContextBootstrapper.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/AbstractTestContextBootstrapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +20,6 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -53,6 +52,7 @@ import org.springframework.test.context.util.TestContextSpringFactoriesUtils; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; /** @@ -355,7 +355,7 @@ private Set getContextCustomizers(Class testClass, List configAttributes) { List factories = getContextCustomizerFactories(testClass); - Set customizers = new LinkedHashSet<>(factories.size()); + Set customizers = CollectionUtils.newLinkedHashSet(factories.size()); for (ContextCustomizerFactory factory : factories) { ContextCustomizer customizer = factory.createContextCustomizer(testClass, configAttributes); if (customizer != null) { diff --git a/spring-test/src/main/java/org/springframework/test/context/support/AnnotationConfigContextLoaderUtils.java b/spring-test/src/main/java/org/springframework/test/context/support/AnnotationConfigContextLoaderUtils.java index c63512a12ab1..a531d0c44786 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/AnnotationConfigContextLoaderUtils.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/AnnotationConfigContextLoaderUtils.java @@ -32,7 +32,7 @@ /** * Utility methods for {@link SmartContextLoader SmartContextLoaders} that deal - * with component classes (e.g., {@link Configuration @Configuration} classes). + * with component classes (for example, {@link Configuration @Configuration} classes). * * @author Sam Brannen * @since 3.2 diff --git a/spring-test/src/main/java/org/springframework/test/context/support/CommonCachesTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/support/CommonCachesTestExecutionListener.java new file mode 100644 index 000000000000..a3bd7bed777c --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/support/CommonCachesTestExecutionListener.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.support; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.test.context.TestContext; + +/** + * {@code TestExecutionListener} which makes sure that common caches are cleared + * once they are no longer required. + * + *

    Clears the resource caches of the {@link ApplicationContext} since they are + * only required during the bean initialization phase. Runs after + * {@link DirtiesContextTestExecutionListener} since dirtying the context will + * close it and remove it from the context cache, making it unnecessary to clear + * the associated resource caches. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class CommonCachesTestExecutionListener extends AbstractTestExecutionListener { + + /** + * The {@link #getOrder() order} value for this listener: {@value}. + * @since 6.2.3 + */ + public static final int ORDER = 3005; + + + /** + * Returns {@value #ORDER}, which ensures that the {@code CommonCachesTestExecutionListener} + * is ordered after the + * {@link DirtiesContextTestExecutionListener DirtiesContextTestExecutionListener} and before the + * {@link org.springframework.test.context.transaction.TransactionalTestExecutionListener + * TransactionalTestExecutionListener}. + */ + @Override + public final int getOrder() { + return ORDER; + } + + @Override + public void afterTestClass(TestContext testContext) throws Exception { + if (testContext.hasApplicationContext()) { + ApplicationContext applicationContext = testContext.getApplicationContext(); + if (applicationContext instanceof AbstractApplicationContext ctx) { + ctx.clearResourceCaches(); + } + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/support/ContextLoaderUtils.java b/spring-test/src/main/java/org/springframework/test/context/support/ContextLoaderUtils.java index 96bcce762327..d768a8cfaf2e 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/ContextLoaderUtils.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/ContextLoaderUtils.java @@ -232,6 +232,7 @@ static Map> buildContextHierarchyMa * @throws IllegalArgumentException if the supplied class is {@code null} or if * {@code @ContextConfiguration} is not present on the supplied class */ + @SuppressWarnings("NullAway") static List resolveContextConfigurationAttributes(Class testClass) { Assert.notNull(testClass, "Class must not be null"); diff --git a/spring-test/src/main/java/org/springframework/test/context/support/DependencyInjectionTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/support/DependencyInjectionTestExecutionListener.java index fef799f259c3..6504fd86868a 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/DependencyInjectionTestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/DependencyInjectionTestExecutionListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,12 @@ */ public class DependencyInjectionTestExecutionListener extends AbstractTestExecutionListener { + /** + * The {@link #getOrder() order} value for this listener: {@value}. + * @since 6.2.3 + */ + public static final int ORDER = 2000; + /** * Attribute name for a {@link TestContext} attribute which indicates * whether the dependencies of a test instance should be @@ -47,7 +53,7 @@ public class DependencyInjectionTestExecutionListener extends AbstractTestExecut * dependencies will be injected in * {@link #prepareTestInstance(TestContext) prepareTestInstance()} in any * case. - *

    Clients of a {@link TestContext} (e.g., other + *

    Clients of a {@link TestContext} (for example, other * {@link org.springframework.test.context.TestExecutionListener TestExecutionListeners}) * may therefore choose to set this attribute to signal that dependencies * should be reinjected between execution of individual test @@ -63,11 +69,18 @@ public class DependencyInjectionTestExecutionListener extends AbstractTestExecut /** - * Returns {@code 2000}. + * Returns {@value #ORDER}, which ensures that the {@code DependencyInjectionTestExecutionListener} + * is ordered after the + * {@link DirtiesContextBeforeModesTestExecutionListener DirtiesContextBeforeModesTestExecutionListener} + * and the {@link org.springframework.test.context.bean.override.BeanOverrideTestExecutionListener + * BeanOverrideTestExecutionListener} and before the + * {@link org.springframework.test.context.observation.MicrometerObservationRegistryTestExecutionListener + * MicrometerObservationRegistryTestExecutionListener} and the + * {@link DirtiesContextTestExecutionListener DirtiesContextTestExecutionListener}. */ @Override public final int getOrder() { - return 2000; + return ORDER; } /** diff --git a/spring-test/src/main/java/org/springframework/test/context/support/DirtiesContextBeforeModesTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/support/DirtiesContextBeforeModesTestExecutionListener.java index 54fca1b8e100..cb21dcd95396 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/DirtiesContextBeforeModesTestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/DirtiesContextBeforeModesTestExecutionListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2025 the original author 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,11 +55,24 @@ public class DirtiesContextBeforeModesTestExecutionListener extends AbstractDirtiesContextTestExecutionListener { /** - * Returns {@code 1500}. + * The {@link #getOrder() order} value for this listener: {@value}. + * @since 6.2.3 + */ + public static final int ORDER = 1500; + + /** + * Returns {@value #ORDER}, which ensures that the + * {@code DirtiesContextBeforeModesTestExecutionListener} is ordered after the + * {@link org.springframework.test.context.web.ServletTestExecutionListener + * ServletTestExecutionListener} and before the + * {@link org.springframework.test.context.event.ApplicationEventsTestExecutionListener + * ApplicationEventsTestExecutionListener} and the + * {@link org.springframework.test.context.bean.override.BeanOverrideTestExecutionListener + * BeanOverrideTestExecutionListener}. */ @Override public final int getOrder() { - return 1500; + return ORDER; } /** diff --git a/spring-test/src/main/java/org/springframework/test/context/support/DirtiesContextTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/support/DirtiesContextTestExecutionListener.java index 91aa1e794d3c..290103c0302a 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/DirtiesContextTestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/DirtiesContextTestExecutionListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2025 the original author 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,11 +55,20 @@ public class DirtiesContextTestExecutionListener extends AbstractDirtiesContextTestExecutionListener { /** - * Returns {@code 3000}. + * The {@link #getOrder() order} value for this listener: {@value}. + * @since 6.2.3 + */ + public static final int ORDER = 3000; + + /** + * Returns {@value #ORDER}, which ensures that the {@code DirtiesContextTestExecutionListener} + * is ordered after the + * {@link DependencyInjectionTestExecutionListener DependencyInjectionTestExecutionListener} + * and before the {@link CommonCachesTestExecutionListener CommonCachesTestExecutionListener}. */ @Override public final int getOrder() { - return 3000; + return ORDER; } /** diff --git a/spring-test/src/main/java/org/springframework/test/context/support/DynamicPropertiesContextCustomizer.java b/spring-test/src/main/java/org/springframework/test/context/support/DynamicPropertiesContextCustomizer.java index 266782314aaa..bc70c3606613 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/DynamicPropertiesContextCustomizer.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/DynamicPropertiesContextCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,77 +18,75 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; import java.util.Set; -import java.util.function.Supplier; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.lang.Nullable; import org.springframework.test.context.ContextCustomizer; import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.MergedContextConfiguration; import org.springframework.util.Assert; import org.springframework.util.ReflectionUtils; /** - * {@link ContextCustomizer} to support - * {@link DynamicPropertySource @DynamicPropertySource} methods. + * {@link ContextCustomizer} which supports + * {@link org.springframework.test.context.DynamicPropertySource @DynamicPropertySource} + * methods in test classes and registers a {@link DynamicPropertyRegistrarBeanInitializer} + * in the container to eagerly initialize + * {@link org.springframework.test.context.DynamicPropertyRegistrar DynamicPropertyRegistrar} + * beans. * * @author Phillip Webb * @author Sam Brannen * @since 5.2.5 * @see DynamicPropertiesContextCustomizerFactory + * @see DefaultDynamicPropertyRegistry + * @see DynamicPropertyRegistrarBeanInitializer */ class DynamicPropertiesContextCustomizer implements ContextCustomizer { - private static final String PROPERTY_SOURCE_NAME = "Dynamic Test Properties"; - private final Set methods; DynamicPropertiesContextCustomizer(Set methods) { - methods.forEach(this::assertValid); + methods.forEach(DynamicPropertiesContextCustomizer::assertValid); this.methods = methods; } - private void assertValid(Method method) { - Assert.state(Modifier.isStatic(method.getModifiers()), - () -> "@DynamicPropertySource method '" + method.getName() + "' must be static"); - Class[] types = method.getParameterTypes(); - Assert.state(types.length == 1 && types[0] == DynamicPropertyRegistry.class, - () -> "@DynamicPropertySource method '" + method.getName() + "' must accept a single DynamicPropertyRegistry argument"); - } - @Override public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) { - MutablePropertySources sources = context.getEnvironment().getPropertySources(); - sources.addFirst(new DynamicValuesPropertySource(PROPERTY_SOURCE_NAME, buildDynamicPropertiesMap())); - } - - private Map> buildDynamicPropertiesMap() { - Map> map = new LinkedHashMap<>(); - DynamicPropertyRegistry dynamicPropertyRegistry = (name, valueSupplier) -> { - Assert.hasText(name, "'name' must not be null or blank"); - Assert.notNull(valueSupplier, "'valueSupplier' must not be null"); - map.put(name, valueSupplier); - }; - this.methods.forEach(method -> { - ReflectionUtils.makeAccessible(method); - ReflectionUtils.invokeMethod(method, null, dynamicPropertyRegistry); - }); - return Collections.unmodifiableMap(map); + ConfigurableListableBeanFactory beanFactory = context.getBeanFactory(); + if (!(beanFactory instanceof BeanDefinitionRegistry beanDefinitionRegistry)) { + throw new IllegalStateException("BeanFactory must be a BeanDefinitionRegistry"); + } + + if (!beanDefinitionRegistry.containsBeanDefinition(DynamicPropertyRegistrarBeanInitializer.BEAN_NAME)) { + BeanDefinition beanDefinition = new RootBeanDefinition(DynamicPropertyRegistrarBeanInitializer.class); + beanDefinition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + beanDefinitionRegistry.registerBeanDefinition(DynamicPropertyRegistrarBeanInitializer.BEAN_NAME, beanDefinition); + } + + if (!this.methods.isEmpty()) { + ConfigurableEnvironment environment = context.getEnvironment(); + DynamicValuesPropertySource propertySource = DynamicValuesPropertySource.getOrCreate(environment); + DynamicPropertyRegistry registry = propertySource.dynamicPropertyRegistry; + this.methods.forEach(method -> { + ReflectionUtils.makeAccessible(method); + ReflectionUtils.invokeMethod(method, null, registry); + }); + } } Set getMethods() { return this.methods; } - @Override public boolean equals(@Nullable Object other) { return (this == other || (other instanceof DynamicPropertiesContextCustomizer that && @@ -100,4 +98,14 @@ public int hashCode() { return this.methods.hashCode(); } + + private static void assertValid(Method method) { + Assert.state(Modifier.isStatic(method.getModifiers()), + () -> "@DynamicPropertySource method '" + method.getName() + "' must be static"); + Class[] types = method.getParameterTypes(); + Assert.state(types.length == 1 && types[0] == DynamicPropertyRegistry.class, + () -> "@DynamicPropertySource method '" + method.getName() + + "' must accept a single DynamicPropertyRegistry argument"); + } + } diff --git a/spring-test/src/main/java/org/springframework/test/context/support/DynamicPropertiesContextCustomizerFactory.java b/spring-test/src/main/java/org/springframework/test/context/support/DynamicPropertiesContextCustomizerFactory.java index 7f9e3da7798a..ddd11ffe6a96 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/DynamicPropertiesContextCustomizerFactory.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/DynamicPropertiesContextCustomizerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.test.context.support; import java.lang.reflect.Method; +import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -30,8 +31,10 @@ import org.springframework.test.context.TestContextAnnotationUtils; /** - * {@link ContextCustomizerFactory} to support - * {@link DynamicPropertySource @DynamicPropertySource} methods. + * {@link ContextCustomizerFactory} which supports + * {@link DynamicPropertySource @DynamicPropertySource} methods in test classes + * and {@link org.springframework.test.context.DynamicPropertyRegistrar + * DynamicPropertyRegistrar} beans in the container. * * @author Phillip Webb * @author Sam Brannen @@ -49,7 +52,7 @@ public DynamicPropertiesContextCustomizer createContextCustomizer(Class testC Set methods = new LinkedHashSet<>(); findMethods(testClass, methods); if (methods.isEmpty()) { - return null; + methods = Collections.emptySet(); } return new DynamicPropertiesContextCustomizer(methods); } diff --git a/spring-test/src/main/java/org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer.java b/spring-test/src/main/java/org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer.java new file mode 100644 index 000000000000..3f24c6dd8a22 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.support; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.BeanFactoryInitializer; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; +import org.springframework.lang.Nullable; +import org.springframework.test.context.DynamicPropertyRegistrar; +import org.springframework.test.context.DynamicPropertyRegistry; + +/** + * {@link BeanFactoryInitializer} that eagerly initializes {@link DynamicPropertyRegistrar} + * beans. + * + *

    Primarily intended for internal use within the Spring TestContext Framework. + * + * @author Sam Brannen + * @since 6.2 + */ +public class DynamicPropertyRegistrarBeanInitializer implements BeanFactoryInitializer, EnvironmentAware { + + private static final Log logger = LogFactory.getLog(DynamicPropertyRegistrarBeanInitializer.class); + + /** + * The bean name of the internally managed {@code DynamicPropertyRegistrarBeanInitializer}. + */ + static final String BEAN_NAME = + "org.springframework.test.context.support.internalDynamicPropertyRegistrarBeanInitializer"; + + + @Nullable + private ConfigurableEnvironment environment; + + + @Override + public void setEnvironment(Environment environment) { + if (!(environment instanceof ConfigurableEnvironment configurableEnvironment)) { + throw new IllegalArgumentException("Environment must be a ConfigurableEnvironment"); + } + this.environment = configurableEnvironment; + } + + @Override + public void initialize(ListableBeanFactory beanFactory) { + if (this.environment == null) { + throw new IllegalStateException("Environment is required"); + } + String[] beanNames = BeanFactoryUtils.beanNamesForTypeIncludingAncestors( + beanFactory, DynamicPropertyRegistrar.class); + if (beanNames.length > 0) { + DynamicValuesPropertySource propertySource = DynamicValuesPropertySource.getOrCreate(this.environment); + DynamicPropertyRegistry registry = propertySource.dynamicPropertyRegistry; + for (String name : beanNames) { + if (logger.isDebugEnabled()) { + logger.debug("Eagerly initializing DynamicPropertyRegistrar bean '%s'".formatted(name)); + } + DynamicPropertyRegistrar registrar = beanFactory.getBean(name, DynamicPropertyRegistrar.class); + registrar.accept(registry); + } + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/support/DynamicValuesPropertySource.java b/spring-test/src/main/java/org/springframework/test/context/support/DynamicValuesPropertySource.java index b87662215f48..bbe560152eaa 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/DynamicValuesPropertySource.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/DynamicValuesPropertySource.java @@ -16,11 +16,18 @@ package org.springframework.test.context.support; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; import java.util.function.Supplier; +import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertySource; import org.springframework.lang.Nullable; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.util.Assert; import org.springframework.util.function.SupplierUtils; /** @@ -33,15 +40,52 @@ */ class DynamicValuesPropertySource extends MapPropertySource { - @SuppressWarnings({"rawtypes", "unchecked"}) - DynamicValuesPropertySource(String name, Map> valueSuppliers) { - super(name, (Map) valueSuppliers); + static final String PROPERTY_SOURCE_NAME = "Dynamic Test Properties"; + + final DynamicPropertyRegistry dynamicPropertyRegistry; + + + DynamicValuesPropertySource() { + this(Collections.synchronizedMap(new LinkedHashMap<>())); + } + + DynamicValuesPropertySource(Map> valueSuppliers) { + super(PROPERTY_SOURCE_NAME, Collections.unmodifiableMap(valueSuppliers)); + this.dynamicPropertyRegistry = (name, valueSupplier) -> { + Assert.hasText(name, "'name' must not be null or blank"); + Assert.notNull(valueSupplier, "'valueSupplier' must not be null"); + valueSuppliers.put(name, valueSupplier); + }; } + @Override @Nullable public Object getProperty(String name) { return SupplierUtils.resolve(super.getProperty(name)); } + + /** + * Get the {@code DynamicValuesPropertySource} registered in the environment + * or create and register a new {@code DynamicValuesPropertySource} in the + * environment. + */ + static DynamicValuesPropertySource getOrCreate(ConfigurableEnvironment environment) { + MutablePropertySources propertySources = environment.getPropertySources(); + PropertySource propertySource = propertySources.get(PROPERTY_SOURCE_NAME); + if (propertySource instanceof DynamicValuesPropertySource dynamicValuesPropertySource) { + return dynamicValuesPropertySource; + } + else if (propertySource == null) { + DynamicValuesPropertySource dynamicValuesPropertySource = new DynamicValuesPropertySource(); + propertySources.addFirst(dynamicValuesPropertySource); + return dynamicValuesPropertySource; + } + else { + throw new IllegalStateException("PropertySource with name '%s' must be a DynamicValuesPropertySource" + .formatted(PROPERTY_SOURCE_NAME)); + } + } + } diff --git a/spring-test/src/main/java/org/springframework/test/context/support/TestConstructorUtils.java b/spring-test/src/main/java/org/springframework/test/context/support/TestConstructorUtils.java index 0cf4aed9627a..b2dfa3707545 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/TestConstructorUtils.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/TestConstructorUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +19,6 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Constructor; import java.lang.reflect.Executable; -import java.util.LinkedHashSet; import java.util.Set; import org.apache.commons.logging.Log; @@ -33,6 +32,7 @@ import org.springframework.test.context.TestConstructor.AutowireMode; import org.springframework.test.context.TestContextAnnotationUtils; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; /** * Utility methods for working with {@link TestConstructor @TestConstructor}. @@ -49,7 +49,7 @@ public abstract class TestConstructorUtils { private static final Log logger = LogFactory.getLog(TestConstructorUtils.class); - private static final Set> autowiredAnnotationTypes = new LinkedHashSet<>(2); + private static final Set> autowiredAnnotationTypes = CollectionUtils.newLinkedHashSet(2); static { autowiredAnnotationTypes.add(Autowired.class); diff --git a/spring-test/src/main/java/org/springframework/test/context/support/TestPropertySourceUtils.java b/spring-test/src/main/java/org/springframework/test/context/support/TestPropertySourceUtils.java index 2e1c9d83382d..700f38acd868 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/TestPropertySourceUtils.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/TestPropertySourceUtils.java @@ -90,7 +90,7 @@ static MergedTestPropertySources buildMergedTestPropertySources(Class testCla TestPropertySourceAttributes previousAttributes = null; // Iterate over all aggregate levels, where each level is represented by - // a list of merged annotations found at that level (e.g., on a test + // a list of merged annotations found at that level (for example, on a test // class in the class hierarchy). for (List> aggregatedAnnotations : findRepeatableAnnotations(testClass, TestPropertySource.class)) { @@ -135,6 +135,7 @@ else if (!duplicationDetected(currentAttributes, previousAttributes)) { return mergedAttributes; } + @SuppressWarnings("NullAway") private static boolean duplicationDetected(TestPropertySourceAttributes currentAttributes, @Nullable TestPropertySourceAttributes previousAttributes) { diff --git a/spring-test/src/main/java/org/springframework/test/context/transaction/TransactionalTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/transaction/TransactionalTestExecutionListener.java index 0114800545e9..279e1b6b8b08 100644 --- a/spring-test/src/main/java/org/springframework/test/context/transaction/TransactionalTestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/transaction/TransactionalTestExecutionListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author 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 @@ * {@code ApplicationContext} for the test. In case there are multiple * instances of {@code PlatformTransactionManager} within the test's * {@code ApplicationContext}, a qualifier may be declared via - * {@link Transactional @Transactional} (e.g., {@code @Transactional("myTxMgr")} + * {@link Transactional @Transactional} (for example, {@code @Transactional("myTxMgr")} * or {@code @Transactional(transactionManager = "myTxMgr")}, or * {@link org.springframework.transaction.annotation.TransactionManagementConfigurer * TransactionManagementConfigurer} can be implemented by an @@ -148,6 +148,12 @@ */ public class TransactionalTestExecutionListener extends AbstractTestExecutionListener { + /** + * The {@link #getOrder() order} value for this listener: {@value}. + * @since 6.2.3 + */ + public static final int ORDER = 4000; + private static final Log logger = LogFactory.getLog(TransactionalTestExecutionListener.class); // Do not require @Transactional test methods to be public. @@ -177,11 +183,16 @@ private TransactionAttribute findTransactionAttributeInEnclosingClassHierarchy(C /** - * Returns {@code 4000}. + * Returns {@value #ORDER}, which ensures that the {@code TransactionalTestExecutionListener} + * is ordered after the + * {@link org.springframework.test.context.support.CommonCachesTestExecutionListener + * CommonCachesTestExecutionListener} and before the + * {@link org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener + * SqlScriptsTestExecutionListener}. */ @Override public final int getOrder() { - return 4000; + return ORDER; } /** @@ -196,6 +207,7 @@ public final int getOrder() { * @see #getTransactionManager(TestContext, String) */ @Override + @SuppressWarnings("NullAway") public void beforeTestMethod(final TestContext testContext) throws Exception { Method testMethod = testContext.getTestMethod(); Class testClass = testContext.getTestClass(); @@ -414,6 +426,7 @@ protected PlatformTransactionManager getTransactionManager(TestContext testConte * @return the default rollback flag for the supplied test context * @throws Exception if an error occurs while determining the default rollback flag */ + @SuppressWarnings("NullAway") protected final boolean isDefaultRollback(TestContext testContext) throws Exception { Class testClass = testContext.getTestClass(); Rollback rollback = TestContextAnnotationUtils.findMergedAnnotation(testClass, Rollback.class); diff --git a/spring-test/src/main/java/org/springframework/test/context/util/TestContextResourceUtils.java b/spring-test/src/main/java/org/springframework/test/context/util/TestContextResourceUtils.java index ba809f2131a0..ebc3c7c87fd2 100644 --- a/spring-test/src/main/java/org/springframework/test/context/util/TestContextResourceUtils.java +++ b/spring-test/src/main/java/org/springframework/test/context/util/TestContextResourceUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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.stream.Collectors; import java.util.stream.Stream; +import org.springframework.core.env.Environment; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.core.io.support.ResourcePatternUtils; @@ -76,7 +77,7 @@ public static String[] convertToClasspathResourcePaths(Class clazz, String... *

  • A path starting with a slash will be treated as an absolute path * within the classpath, for example: {@code "/org/example/schema.sql"}. * Such a path will be prepended with the {@code classpath:} prefix. - *
  • A path which is already prefixed with a URL protocol (e.g., + *
  • A path which is already prefixed with a URL protocol (for example, * {@code classpath:}, {@code file:}, {@code http:}, etc.) will not have its * protocol modified. * @@ -145,6 +146,28 @@ public static List convertToResourceList(ResourceLoader resourceLoader return stream(resourceLoader, paths).collect(Collectors.toCollection(ArrayList::new)); } + /** + * Convert the supplied paths to a list of {@link Resource} handles using the given + * {@link ResourceLoader} and {@link Environment}. + * @param resourceLoader the {@code ResourceLoader} to use to convert the paths + * @param environment the {@code Environment} to use to resolve property placeholders + * in the paths + * @param paths the paths to be converted + * @return a new, mutable list of resources + * @since 6.2 + * @see #convertToResources(ResourceLoader, String...) + * @see #convertToClasspathResourcePaths + * @see Environment#resolveRequiredPlaceholders(String) + */ + public static List convertToResourceList( + ResourceLoader resourceLoader, Environment environment, String... paths) { + + return Arrays.stream(paths) + .map(environment::resolveRequiredPlaceholders) + .map(resourceLoader::getResource) + .collect(Collectors.toCollection(ArrayList::new)); + } + private static Stream stream(ResourceLoader resourceLoader, String... paths) { return Arrays.stream(paths).map(resourceLoader::getResource); } diff --git a/spring-test/src/main/java/org/springframework/test/context/web/ServletTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/web/ServletTestExecutionListener.java index 6992cf06285c..5bdc11fe1473 100644 --- a/spring-test/src/main/java/org/springframework/test/context/web/ServletTestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/web/ServletTestExecutionListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 the original author 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,6 +64,12 @@ */ public class ServletTestExecutionListener extends AbstractTestExecutionListener { + /** + * The {@link #getOrder() order} value for this listener: {@value}. + * @since 6.2.3 + */ + public static final int ORDER = 1000; + /** * Attribute name for a {@link TestContext} attribute which indicates * whether the {@code ServletTestExecutionListener} should {@linkplain @@ -110,11 +116,14 @@ public class ServletTestExecutionListener extends AbstractTestExecutionListener /** - * Returns {@code 1000}. + * Returns {@value #ORDER}, which ensures that the {@code ServletTestExecutionListener} + * is ordered before the + * {@link org.springframework.test.context.support.DirtiesContextBeforeModesTestExecutionListener + * DirtiesContextBeforeModesTestExecutionListener}. */ @Override public final int getOrder() { - return 1000; + return ORDER; } /** diff --git a/spring-test/src/main/java/org/springframework/test/context/web/WebAppConfiguration.java b/spring-test/src/main/java/org/springframework/test/context/web/WebAppConfiguration.java index 7b5e4520b1cf..52cc39e6b708 100644 --- a/spring-test/src/main/java/org/springframework/test/context/web/WebAppConfiguration.java +++ b/spring-test/src/main/java/org/springframework/test/context/web/WebAppConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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,9 @@ import java.lang.annotation.Target; /** - * {@code @WebAppConfiguration} is a class-level annotation that is used to - * declare that the {@code ApplicationContext} loaded for an integration test - * should be a {@link org.springframework.web.context.WebApplicationContext + * {@code @WebAppConfiguration} is an annotation that can be applied to a test + * class to declare that the {@code ApplicationContext} loaded for an integration + * test should be a {@link org.springframework.web.context.WebApplicationContext * WebApplicationContext}. * *

    The presence of {@code @WebAppConfiguration} on a test class indicates that @@ -60,7 +60,7 @@ /** * The resource path to the root directory of the web application. - *

    A path that does not include a Spring resource prefix (e.g., {@code classpath:}, + *

    A path that does not include a Spring resource prefix (for example, {@code classpath:}, * {@code file:}, etc.) will be interpreted as a file system resource, and a * path should not end with a slash. *

    Defaults to {@code "src/main/webapp"} as a file system resource. Note diff --git a/spring-test/src/main/java/org/springframework/test/context/web/WebMergedContextConfiguration.java b/spring-test/src/main/java/org/springframework/test/context/web/WebMergedContextConfiguration.java index 274af3104173..79c948a30b4a 100644 --- a/spring-test/src/main/java/org/springframework/test/context/web/WebMergedContextConfiguration.java +++ b/spring-test/src/main/java/org/springframework/test/context/web/WebMergedContextConfiguration.java @@ -218,8 +218,8 @@ public String getResourceBasePath() { */ @Override public boolean equals(@Nullable Object other) { - return (this == other || (super.equals(other) && - this.resourceBasePath.equals(((WebMergedContextConfiguration) other).resourceBasePath))); + return (this == other || (super.equals(other) && other instanceof WebMergedContextConfiguration otherConfiguration && + this.resourceBasePath.equals(otherConfiguration.resourceBasePath))); } /** diff --git a/spring-test/src/main/java/org/springframework/test/http/HttpHeadersAssert.java b/spring-test/src/main/java/org/springframework/test/http/HttpHeadersAssert.java new file mode 100644 index 000000000000..ec6d984dc85e --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/http/HttpHeadersAssert.java @@ -0,0 +1,142 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.assertj.core.api.AbstractMapAssert; +import org.assertj.core.api.Assertions; + +import org.springframework.http.HttpHeaders; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to + * {@link HttpHeaders}. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class HttpHeadersAssert extends AbstractMapAssert> { + + private static final ZoneId GMT = ZoneId.of("GMT"); + + + public HttpHeadersAssert(HttpHeaders actual) { + super(actual, HttpHeadersAssert.class); + as("HTTP headers"); + } + + /** + * Verify that the actual HTTP headers contain a header with the given + * {@code name}. + * @param name the name of an expected HTTP header + * @see #containsKey + */ + public HttpHeadersAssert containsHeader(String name) { + return containsKey(name); + } + + /** + * Verify that the actual HTTP headers contain the headers with the given + * {@code names}. + * @param names the names of expected HTTP headers + * @see #containsKeys + */ + public HttpHeadersAssert containsHeaders(String... names) { + return containsKeys(names); + } + + /** + * Verify that the actual HTTP headers do not contain a header with the + * given {@code name}. + * @param name the name of an HTTP header that should not be present + * @see #doesNotContainKey + */ + public HttpHeadersAssert doesNotContainHeader(String name) { + return doesNotContainKey(name); + } + + /** + * Verify that the actual HTTP headers do not contain any of the headers + * with the given {@code names}. + * @param names the names of HTTP headers that should not be present + * @since 6.2.2 + * @see #doesNotContainKeys + */ + public HttpHeadersAssert doesNotContainHeaders(String... names) { + return doesNotContainKeys(names); + } + + /** + * Verify that the actual HTTP headers do not contain any of the headers + * with the given {@code names}. + * @param names the names of HTTP headers that should not be present + * @see #doesNotContainKeys + * @deprecated in favor of {@link #doesNotContainHeaders(String...)} + */ + @Deprecated(since = "6.2.2", forRemoval = true) + public HttpHeadersAssert doesNotContainsHeaders(String... names) { + return doesNotContainKeys(names); + } + + /** + * Verify that the actual HTTP headers contain a header with the given + * {@code name} and {@link String} {@code value}. + * @param name the name of the header + * @param value the expected value of the header + */ + public HttpHeadersAssert hasValue(String name, String value) { + containsKey(name); + Assertions.assertThat(this.actual.getFirst(name)) + .as("check primary value for HTTP header '%s'", name) + .isEqualTo(value); + return this.myself; + } + + /** + * Verify that the actual HTTP headers contain a header with the given + * {@code name} and {@code long} {@code value}. + * @param name the name of the header + * @param value the expected value of the header + */ + public HttpHeadersAssert hasValue(String name, long value) { + containsKey(name); + Assertions.assertThat(this.actual.getFirst(name)) + .as("check primary long value for HTTP header '%s'", name) + .asLong().isEqualTo(value); + return this.myself; + } + + /** + * Verify that the actual HTTP headers contain a header with the given + * {@code name} and {@link Instant} {@code value}. + * @param name the name of the header + * @param value the expected value of the header + */ + public HttpHeadersAssert hasValue(String name, Instant value) { + containsKey(name); + Assertions.assertThat(this.actual.getFirstZonedDateTime(name)) + .as("check primary date value for HTTP header '%s'", name) + .isCloseTo(ZonedDateTime.ofInstant(value, GMT), Assertions.within(999, ChronoUnit.MILLIS)); + return this.myself; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/http/HttpMessageContentConverter.java b/spring-test/src/main/java/org/springframework/test/http/HttpMessageContentConverter.java new file mode 100644 index 000000000000..f8a80b009849 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/http/HttpMessageContentConverter.java @@ -0,0 +1,156 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.List; +import java.util.stream.StreamSupport; + +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.SmartHttpMessageConverter; +import org.springframework.mock.http.MockHttpInputMessage; +import org.springframework.mock.http.MockHttpOutputMessage; +import org.springframework.util.Assert; +import org.springframework.util.function.SingletonSupplier; + +/** + * Convert HTTP message content for testing purposes. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class HttpMessageContentConverter { + + private static final MediaType JSON = MediaType.APPLICATION_JSON; + + private final List> messageConverters; + + HttpMessageContentConverter(Iterable> messageConverters) { + this.messageConverters = StreamSupport.stream(messageConverters.spliterator(), false).toList(); + Assert.notEmpty(this.messageConverters, "At least one message converter needs to be specified"); + } + + + /** + * Create an instance with an iterable of the candidates to use. + * @param candidates the candidates + */ + public static HttpMessageContentConverter of(Iterable> candidates) { + return new HttpMessageContentConverter(candidates); + } + + /** + * Create an instance with a vararg of the candidates to use. + * @param candidates the candidates + */ + public static HttpMessageContentConverter of(HttpMessageConverter... candidates) { + return new HttpMessageContentConverter(Arrays.asList(candidates)); + } + + + /** + * Convert the given {@link HttpInputMessage} whose content must match the + * given {@link MediaType} to the requested {@code targetType}. + * @param message an input message + * @param mediaType the media type of the input + * @param targetType the target type + * @param the converted object type + * @return a value of the given {@code targetType} + */ + @SuppressWarnings("unchecked") + public T convert(HttpInputMessage message, MediaType mediaType, ResolvableType targetType) + throws IOException, HttpMessageNotReadableException { + Class contextClass = targetType.getRawClass(); + SingletonSupplier javaType = SingletonSupplier.of(targetType::getType); + for (HttpMessageConverter messageConverter : this.messageConverters) { + if (messageConverter instanceof GenericHttpMessageConverter genericMessageConverter) { + Type type = javaType.obtain(); + if (genericMessageConverter.canRead(type, contextClass, mediaType)) { + return (T) genericMessageConverter.read(type, contextClass, message); + } + } + else if (messageConverter instanceof SmartHttpMessageConverter smartMessageConverter) { + if (smartMessageConverter.canRead(targetType, mediaType)) { + return (T) smartMessageConverter.read(targetType, message, null); + } + } + else { + Class targetClass = (contextClass != null ? contextClass : Object.class); + if (messageConverter.canRead(targetClass, mediaType)) { + HttpMessageConverter simpleMessageConverter = (HttpMessageConverter) messageConverter; + Class clazz = (Class) targetClass; + return simpleMessageConverter.read(clazz, message); + } + } + } + throw new IllegalStateException("No converter found to read [%s] to [%s]".formatted(mediaType, targetType)); + } + + /** + * Convert the given raw value to the given {@code targetType} by writing + * it first to JSON and reading it back. + * @param value the value to convert + * @param targetType the target type + * @param the converted object type + * @return a value of the given {@code targetType} + */ + public T convertViaJson(Object value, ResolvableType targetType) throws IOException { + MockHttpOutputMessage outputMessage = convertToJson(value, ResolvableType.forInstance(value)); + return convert(fromHttpOutputMessage(outputMessage), JSON, targetType); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private MockHttpOutputMessage convertToJson(Object value, ResolvableType valueType) throws IOException { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + Class valueClass = value.getClass(); + SingletonSupplier javaType = SingletonSupplier.of(valueType::getType); + for (HttpMessageConverter messageConverter : this.messageConverters) { + if (messageConverter instanceof GenericHttpMessageConverter genericMessageConverter) { + Type type = javaType.obtain(); + if (genericMessageConverter.canWrite(type, valueClass, JSON)) { + genericMessageConverter.write(value, type, JSON, outputMessage); + return outputMessage; + } + } + else if (messageConverter instanceof SmartHttpMessageConverter smartMessageConverter) { + if (smartMessageConverter.canWrite(valueType, valueClass, JSON)) { + smartMessageConverter.write(value, valueType, JSON, outputMessage, null); + return outputMessage; + } + } + else if (messageConverter.canWrite(valueClass, JSON)) { + ((HttpMessageConverter) messageConverter).write(value, JSON, outputMessage); + return outputMessage; + } + } + throw new IllegalStateException("No converter found to convert [%s] to JSON".formatted(valueType)); + } + + private static HttpInputMessage fromHttpOutputMessage(MockHttpOutputMessage message) { + MockHttpInputMessage inputMessage = new MockHttpInputMessage(message.getBodyAsBytes()); + inputMessage.getHeaders().addAll(message.getHeaders()); + return inputMessage; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/http/MediaTypeAssert.java b/spring-test/src/main/java/org/springframework/test/http/MediaTypeAssert.java new file mode 100644 index 000000000000..a25b39094d85 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/http/MediaTypeAssert.java @@ -0,0 +1,123 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http; + +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.Assertions; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.internal.Failures; + +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied + * to a {@link MediaType}. + * + * @author Brian Clozel + * @author Stephane Nicoll + * @since 6.2 + */ +public class MediaTypeAssert extends AbstractObjectAssert { + + public MediaTypeAssert(@Nullable String actual) { + this(StringUtils.hasText(actual) ? MediaType.parseMediaType(actual) : null); + } + + public MediaTypeAssert(@Nullable MediaType mediaType) { + super(mediaType, MediaTypeAssert.class); + as("Media type"); + } + + + /** + * Verify that the actual media type is equal to the given string + * representation. + * @param mediaType the expected media type, as a String to be parsed + * into a MediaType + */ + public MediaTypeAssert isEqualTo(String mediaType) { + return isEqualTo(parseMediaType(mediaType)); + } + + /** + * Verify that the actual media type is not equal to the given string + * representation. + * @param mediaType the given media type, as a String to be parsed + * into a MediaType + */ + public MediaTypeAssert isNotEqualTo(String mediaType) { + return isNotEqualTo(parseMediaType(mediaType)); + } + + /** + * Verify that the actual media type is + * {@linkplain MediaType#isCompatibleWith(MediaType) compatible} with the + * given one. + *

    Example:

    
    +	 * // Check that actual is compatible with "application/json"
    +	 * assertThat(mediaType).isCompatibleWith(MediaType.APPLICATION_JSON);
    +	 * 
    + * @param mediaType the media type with which to compare + */ + public MediaTypeAssert isCompatibleWith(MediaType mediaType) { + Assertions.assertThat(this.actual) + .withFailMessage("Expecting null to be compatible with '%s'", mediaType).isNotNull(); + Assertions.assertThat(mediaType) + .withFailMessage("Expecting '%s' to be compatible with null", this.actual).isNotNull(); + Assertions.assertThat(this.actual.isCompatibleWith(mediaType)) + .as("check media type '%s' is compatible with '%s'", this.actual.toString(), mediaType.toString()) + .isTrue(); + return this; + } + + /** + * Verify that the actual media type is + * {@linkplain MediaType#isCompatibleWith(MediaType) compatible} with the + * given one. + *

    Example:

    
    +	 * // Check that actual is compatible with "text/plain"
    +	 * assertThat(mediaType).isCompatibleWith("text/plain");
    +	 * 
    + * @param mediaType the media type with which to compare, as a String + * to be parsed into a MediaType + */ + public MediaTypeAssert isCompatibleWith(String mediaType) { + return isCompatibleWith(parseMediaType(mediaType)); + } + + + @SuppressWarnings("NullAway") + private MediaType parseMediaType(String value) { + try { + return MediaType.parseMediaType(value); + } + catch (InvalidMediaTypeException ex) { + throw Failures.instance().failure(this.info, new ShouldBeValidMediaType(value, ex.getMessage())); + } + } + + private static final class ShouldBeValidMediaType extends BasicErrorMessageFactory { + + private ShouldBeValidMediaType(String mediaType, String errorMessage) { + super("%nExpecting:%n %s%nTo be a valid media type but got:%n %s%n", mediaType, errorMessage); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/http/package-info.java b/spring-test/src/main/java/org/springframework/test/http/package-info.java new file mode 100644 index 000000000000..6613b8a01284 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/http/package-info.java @@ -0,0 +1,9 @@ +/** + * Test support for HTTP concepts. + */ +@NonNullApi +@NonNullFields +package org.springframework.test.http; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-test/src/main/java/org/springframework/test/json/AbstractJsonContentAssert.java b/spring-test/src/main/java/org/springframework/test/json/AbstractJsonContentAssert.java new file mode 100644 index 000000000000..e034682b598a --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/AbstractJsonContentAssert.java @@ -0,0 +1,601 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.json; + +import java.io.File; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.function.Consumer; + +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.PathNotFoundException; +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.AssertFactory; +import org.assertj.core.api.AssertProvider; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.internal.Failures; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.mock.http.MockHttpInputMessage; +import org.springframework.test.http.HttpMessageContentConverter; +import org.springframework.util.Assert; + +/** + * Base AssertJ {@linkplain org.assertj.core.api.Assert assertions} that can be + * applied to a JSON document. + * + *

    Supports evaluating {@linkplain JsonPath JSON path} expressions and + * extracting a part of the document for further {@linkplain JsonPathValueAssert + * assertions} on the value. + * + *

    Also supports comparing the JSON document against a target, using a + * {@linkplain JsonComparator JSON Comparator}. Resources that are loaded from + * the classpath can be relative if a {@linkplain #withResourceLoadClass(Class) + * class} is provided. By default, {@code UTF-8} is used to load resources, + * but this can be overridden using {@link #withCharset(Charset)}. + * + * @author Stephane Nicoll + * @author Phillip Webb + * @author Andy Wilkinson + * @author Diego Berrueta + * @author Camille Vienot + * @since 6.2 + * @param the type of assertions + */ +public abstract class AbstractJsonContentAssert> + extends AbstractObjectAssert { + + private static final Failures failures = Failures.instance(); + + + @Nullable + private final HttpMessageContentConverter contentConverter; + + @Nullable + private Class resourceLoadClass; + + @Nullable + private Charset charset; + + private JsonLoader jsonLoader; + + /** + * Create an assert for the given JSON document. + * @param actual the JSON document to assert + * @param selfType the implementation type of this assert + */ + protected AbstractJsonContentAssert(@Nullable JsonContent actual, Class selfType) { + super(actual, selfType); + this.contentConverter = (actual != null ? actual.getContentConverter() : null); + this.jsonLoader = new JsonLoader(null, null); + as("JSON content"); + } + + /** + * Verify that the actual value can be converted to an instance of the + * given {@code target}, and produce a new {@linkplain AbstractObjectAssert + * assertion} object narrowed to that type. + * @param target the {@linkplain Class type} to convert the actual value to + */ + public AbstractObjectAssert convertTo(Class target) { + isNotNull(); + T value = convertToTargetType(target); + return Assertions.assertThat(value); + } + + /** + * Verify that the actual value can be converted to an instance of the type + * defined by the given {@link AssertFactory} and return a new Assert narrowed + * to that type. + *

    {@link InstanceOfAssertFactories} provides static factories for all the + * types supported by {@link Assertions#assertThat}. Additional factories can + * be created by implementing {@link AssertFactory}. + *

    Example:

    
    +	 * // Check that the JSON document is an array of 3 users
    +	 * assertThat(json).convertTo(InstanceOfAssertFactories.list(User.class))
    +	 *         hasSize(3); // ListAssert of User
    +	 * 
    + * @param assertFactory the {@link AssertFactory} to use to produce a narrowed + * Assert for the type that it defines. + */ + public > ASSERT convertTo(AssertFactory assertFactory) { + isNotNull(); + return assertFactory.createAssert(this::convertToTargetType); + } + + private T convertToTargetType(Type targetType) { + String json = this.actual.getJson(); + if (this.contentConverter == null) { + throw new IllegalStateException( + "No JSON message converter available to convert %s".formatted(json)); + } + try { + return this.contentConverter.convert(fromJson(json), MediaType.APPLICATION_JSON, + ResolvableType.forType(targetType)); + } + catch (Exception ex) { + throw failure(new ValueProcessingFailed(json, + "To convert successfully to:%n %s%nBut it failed:%n %s%n".formatted( + targetType.getTypeName(), ex.getMessage()))); + } + } + + private HttpInputMessage fromJson(String json) { + MockHttpInputMessage inputMessage = new MockHttpInputMessage(json.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + return inputMessage; + } + + + // JsonPath support + + /** + * Verify that the given JSON {@code path} is present, and extract the JSON + * value for further {@linkplain JsonPathValueAssert assertions}. + * @param path the {@link JsonPath} expression + * @see #hasPathSatisfying(String, Consumer) + */ + public JsonPathValueAssert extractingPath(String path) { + Object value = new JsonPathValue(path).getValue(); + return new JsonPathValueAssert(value, path, this.contentConverter); + } + + /** + * Verify that the given JSON {@code path} is present with a JSON value + * satisfying the given {@code valueRequirements}. + * @param path the {@link JsonPath} expression + * @param valueRequirements a {@link Consumer} of the assertion object + */ + public SELF hasPathSatisfying(String path, Consumer> valueRequirements) { + Object value = new JsonPathValue(path).assertHasPath(); + JsonPathValueAssert valueAssert = new JsonPathValueAssert(value, path, this.contentConverter); + valueRequirements.accept(() -> valueAssert); + return this.myself; + } + + /** + * Verify that the given JSON {@code path} matches. For paths with an + * operator, this validates that the path expression is valid, but does not + * validate that it yield any results. + * @param path the {@link JsonPath} expression + */ + public SELF hasPath(String path) { + new JsonPathValue(path).assertHasPath(); + return this.myself; + } + + /** + * Verify that the given JSON {@code path} does not match. + * @param path the {@link JsonPath} expression + */ + public SELF doesNotHavePath(String path) { + new JsonPathValue(path).assertDoesNotHavePath(); + return this.myself; + } + + // JsonAssert support + + /** + * Verify that the actual value is {@linkplain JsonCompareMode#STRICT strictly} + * equal to the given JSON. The {@code expected} value can contain the JSON + * itself or, if it ends with {@code .json}, the name of a resource to be + * loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + * @see #isEqualTo(CharSequence, JsonCompareMode) + */ + public SELF isEqualTo(@Nullable CharSequence expected) { + return isEqualTo(expected, JsonCompareMode.STRICT); + } + + /** + * Verify that the actual value is equal to the given JSON. The + * {@code expected} value can contain the JSON itself or, if it ends with + * {@code .json}, the name of a resource to be loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + * @param compareMode the compare mode used when checking + */ + public SELF isEqualTo(@Nullable CharSequence expected, JsonCompareMode compareMode) { + String expectedJson = this.jsonLoader.getJson(expected); + return assertIsMatch(compare(expectedJson, compareMode)); + } + + /** + * Verify that the actual value is equal to the given JSON {@link Resource}. + *

    The resource abstraction allows to provide several input types: + *

      + *
    • a {@code byte} array, using {@link ByteArrayResource}
    • + *
    • a {@code classpath} resource, using {@link ClassPathResource}
    • + *
    • a {@link File} or {@link Path}, using {@link FileSystemResource}
    • + *
    • an {@link InputStream}, using {@link InputStreamResource}
    • + *
    + * @param expected a resource containing the expected JSON + * @param compareMode the compare mode used when checking + */ + public SELF isEqualTo(Resource expected, JsonCompareMode compareMode) { + String expectedJson = this.jsonLoader.getJson(expected); + return assertIsMatch(compare(expectedJson, compareMode)); + } + + /** + * Verify that the actual value is equal to the given JSON. The + * {@code expected} value can contain the JSON itself or, if it ends with + * {@code .json}, the name of a resource to be loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + * @param comparator the comparator used when checking + */ + public SELF isEqualTo(@Nullable CharSequence expected, JsonComparator comparator) { + String expectedJson = this.jsonLoader.getJson(expected); + return assertIsMatch(compare(expectedJson, comparator)); + } + + /** + * Verify that the actual value is equal to the given JSON {@link Resource}. + *

    The resource abstraction allows to provide several input types: + *

      + *
    • a {@code byte} array, using {@link ByteArrayResource}
    • + *
    • a {@code classpath} resource, using {@link ClassPathResource}
    • + *
    • a {@link File} or {@link Path}, using {@link FileSystemResource}
    • + *
    • an {@link InputStream}, using {@link InputStreamResource}
    • + *
    + * @param expected a resource containing the expected JSON + * @param comparator the comparator used when checking + */ + public SELF isEqualTo(Resource expected, JsonComparator comparator) { + String expectedJson = this.jsonLoader.getJson(expected); + return assertIsMatch(compare(expectedJson, comparator)); + } + + /** + * Verify that the actual value is {@link JsonCompareMode#LENIENT leniently} + * equal to the given JSON. The {@code expected} value can contain the JSON + * itself or, if it ends with {@code .json}, the name of a resource to be + * loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + */ + public SELF isLenientlyEqualTo(@Nullable CharSequence expected) { + return isEqualTo(expected, JsonCompareMode.LENIENT); + } + + /** + * Verify that the actual value is {@link JsonCompareMode#LENIENT leniently} + * equal to the given JSON {@link Resource}. + *

    The resource abstraction allows to provide several input types: + *

      + *
    • a {@code byte} array, using {@link ByteArrayResource}
    • + *
    • a {@code classpath} resource, using {@link ClassPathResource}
    • + *
    • a {@link File} or {@link Path}, using {@link FileSystemResource}
    • + *
    • an {@link InputStream}, using {@link InputStreamResource}
    • + *
    + * @param expected a resource containing the expected JSON + */ + public SELF isLenientlyEqualTo(Resource expected) { + return isEqualTo(expected, JsonCompareMode.LENIENT); + } + + /** + * Verify that the actual value is {@link JsonCompareMode#STRICT strictly} + * equal to the given JSON. The {@code expected} value can contain the JSON + * itself or, if it ends with {@code .json}, the name of a resource to be + * loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + */ + public SELF isStrictlyEqualTo(@Nullable CharSequence expected) { + return isEqualTo(expected, JsonCompareMode.STRICT); + } + + /** + * Verify that the actual value is {@link JsonCompareMode#STRICT strictly} + * equal to the given JSON {@link Resource}. + *

    The resource abstraction allows to provide several input types: + *

      + *
    • a {@code byte} array, using {@link ByteArrayResource}
    • + *
    • a {@code classpath} resource, using {@link ClassPathResource}
    • + *
    • a {@link File} or {@link Path}, using {@link FileSystemResource}
    • + *
    • an {@link InputStream}, using {@link InputStreamResource}
    • + *
    + * @param expected a resource containing the expected JSON + */ + public SELF isStrictlyEqualTo(Resource expected) { + return isEqualTo(expected, JsonCompareMode.STRICT); + } + + /** + * Verify that the actual value is {@linkplain JsonCompareMode#STRICT strictly} + * not equal to the given JSON. The {@code expected} value can contain the + * JSON itself or, if it ends with {@code .json}, the name of a resource to + * be loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + * @see #isNotEqualTo(CharSequence, JsonCompareMode) + */ + public SELF isNotEqualTo(@Nullable CharSequence expected) { + return isNotEqualTo(expected, JsonCompareMode.STRICT); + } + + /** + * Verify that the actual value is not equal to the given JSON. The + * {@code expected} value can contain the JSON itself or, if it ends with + * {@code .json}, the name of a resource to be loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + * @param compareMode the compare mode used when checking + */ + public SELF isNotEqualTo(@Nullable CharSequence expected, JsonCompareMode compareMode) { + String expectedJson = this.jsonLoader.getJson(expected); + return assertIsMismatch(compare(expectedJson, compareMode)); + } + + /** + * Verify that the actual value is not equal to the given JSON {@link Resource}. + *

    The resource abstraction allows to provide several input types: + *

      + *
    • a {@code byte} array, using {@link ByteArrayResource}
    • + *
    • a {@code classpath} resource, using {@link ClassPathResource}
    • + *
    • a {@link File} or {@link Path}, using {@link FileSystemResource}
    • + *
    • an {@link InputStream}, using {@link InputStreamResource}
    • + *
    + * @param expected a resource containing the expected JSON + * @param compareMode the compare mode used when checking + */ + public SELF isNotEqualTo(Resource expected, JsonCompareMode compareMode) { + String expectedJson = this.jsonLoader.getJson(expected); + return assertIsMismatch(compare(expectedJson, compareMode)); + } + + /** + * Verify that the actual value is not equal to the given JSON. The + * {@code expected} value can contain the JSON itself or, if it ends with + * {@code .json}, the name of a resource to be loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + * @param comparator the comparator used when checking + */ + public SELF isNotEqualTo(@Nullable CharSequence expected, JsonComparator comparator) { + String expectedJson = this.jsonLoader.getJson(expected); + return assertIsMismatch(compare(expectedJson, comparator)); + } + + /** + * Verify that the actual value is not equal to the given JSON {@link Resource}. + *

    The resource abstraction allows to provide several input types: + *

      + *
    • a {@code byte} array, using {@link ByteArrayResource}
    • + *
    • a {@code classpath} resource, using {@link ClassPathResource}
    • + *
    • a {@link File} or {@link Path}, using {@link FileSystemResource}
    • + *
    • an {@link InputStream}, using {@link InputStreamResource}
    • + *
    + * @param expected a resource containing the expected JSON + * @param comparator the comparator used when checking + */ + public SELF isNotEqualTo(Resource expected, JsonComparator comparator) { + String expectedJson = this.jsonLoader.getJson(expected); + return assertIsMismatch(compare(expectedJson, comparator)); + } + + /** + * Verify that the actual value is not {@link JsonCompareMode#LENIENT + * leniently} equal to the given JSON. The {@code expected} value can + * contain the JSON itself or, if it ends with {@code .json}, the name of a + * resource to be loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + */ + public SELF isNotLenientlyEqualTo(@Nullable CharSequence expected) { + return isNotEqualTo(expected, JsonCompareMode.LENIENT); + } + + /** + * Verify that the actual value is not {@link JsonCompareMode#LENIENT + * leniently} equal to the given JSON {@link Resource}. + *

    The resource abstraction allows to provide several input types: + *

      + *
    • a {@code byte} array, using {@link ByteArrayResource}
    • + *
    • a {@code classpath} resource, using {@link ClassPathResource}
    • + *
    • a {@link File} or {@link Path}, using {@link FileSystemResource}
    • + *
    • an {@link InputStream}, using {@link InputStreamResource}
    • + *
    + * @param expected a resource containing the expected JSON + */ + public SELF isNotLenientlyEqualTo(Resource expected) { + return isNotEqualTo(expected, JsonCompareMode.LENIENT); + } + + /** + * Verify that the actual value is not {@link JsonCompareMode#STRICT + * strictly} equal to the given JSON. The {@code expected} value can + * contain the JSON itself or, if it ends with {@code .json}, the name of a + * resource to be loaded from the classpath. + * @param expected the expected JSON or the name of a resource containing + * the expected JSON + */ + public SELF isNotStrictlyEqualTo(@Nullable CharSequence expected) { + return isNotEqualTo(expected, JsonCompareMode.STRICT); + } + + /** + * Verify that the actual value is not {@link JsonCompareMode#STRICT + * strictly} equal to the given JSON {@link Resource}. + *

    The resource abstraction allows to provide several input types: + *

      + *
    • a {@code byte} array, using {@link ByteArrayResource}
    • + *
    • a {@code classpath} resource, using {@link ClassPathResource}
    • + *
    • a {@link File} or {@link Path}, using {@link FileSystemResource}
    • + *
    • an {@link InputStream}, using {@link InputStreamResource}
    • + *
    + * @param expected a resource containing the expected JSON + */ + public SELF isNotStrictlyEqualTo(Resource expected) { + return isNotEqualTo(expected, JsonCompareMode.STRICT); + } + + /** + * Override the class used to load resources. + *

    Resources can be loaded from an absolute location or relative to the + * specified class. For instance, specifying {@code com.example.MyClass} as + * the resource class allows you to use "my-file.json" to load + * {@code /com/example/my-file.json}. + * @param resourceLoadClass the class used to load resources, or {@code null} + * to only use absolute paths + */ + public SELF withResourceLoadClass(@Nullable Class resourceLoadClass) { + this.resourceLoadClass = resourceLoadClass; + this.jsonLoader = new JsonLoader(resourceLoadClass, this.charset); + return this.myself; + } + + /** + * Override the {@link Charset} to use to load resources. + *

    By default, resources are loaded using {@code UTF-8}. + * @param charset the charset to use, or {@code null} to use the default + */ + public SELF withCharset(@Nullable Charset charset) { + this.charset = charset; + this.jsonLoader = new JsonLoader(this.resourceLoadClass, charset); + return this.myself; + } + + @Nullable + private String toJsonString() { + return (this.actual != null ? this.actual.getJson() : null); + } + + @SuppressWarnings("NullAway") + private String toNonNullJsonString() { + String jsonString = toJsonString(); + Assertions.assertThat(jsonString).as("JSON content").isNotNull(); + return jsonString; + } + + private JsonComparison compare(@Nullable CharSequence expectedJson, JsonCompareMode compareMode) { + return compare(expectedJson, JsonAssert.comparator(compareMode)); + } + + private JsonComparison compare(@Nullable CharSequence expectedJson, JsonComparator comparator) { + return comparator.compare((expectedJson != null) ? expectedJson.toString() : null, toJsonString()); + } + + private SELF assertIsMatch(JsonComparison result) { + return assertComparison(result, JsonComparison.Result.MATCH); + } + + private SELF assertIsMismatch(JsonComparison result) { + return assertComparison(result, JsonComparison.Result.MISMATCH); + } + + private SELF assertComparison(JsonComparison jsonComparison, JsonComparison.Result requiredResult) { + if (jsonComparison.getResult() != requiredResult) { + failWithMessage("JSON comparison failure: %s", jsonComparison.getMessage()); + } + return this.myself; + } + + private AssertionError failure(BasicErrorMessageFactory errorMessageFactory) { + throw failures.failure(this.info, errorMessageFactory); + } + + + /** + * A {@link JsonPath} value. + */ + private class JsonPathValue { + + private final String path; + + private final String json; + + private final JsonPath jsonPath; + + JsonPathValue(String path) { + Assert.hasText(path, "'path' must not be null or empty"); + this.path = path; + this.json = toNonNullJsonString(); + this.jsonPath = JsonPath.compile(this.path); + } + + @Nullable + Object assertHasPath() { + return getValue(); + } + + void assertDoesNotHavePath() { + try { + read(); + throw failure(new JsonPathNotExpected(this.json, this.path)); + } + catch (PathNotFoundException ignore) { + } + } + + @Nullable + Object getValue() { + try { + return read(); + } + catch (PathNotFoundException ex) { + throw failure(new JsonPathNotFound(this.json, this.path)); + } + } + + @Nullable + private Object read() { + return this.jsonPath.read(this.json); + } + + + static final class JsonPathNotFound extends BasicErrorMessageFactory { + + private JsonPathNotFound(String actual, String path) { + super("%nExpecting:%n %s%nTo match JSON path:%n %s%n", actual, path); + } + } + + static final class JsonPathNotExpected extends BasicErrorMessageFactory { + + private JsonPathNotExpected(String actual, String path) { + super("%nExpecting:%n %s%nNot to match JSON path:%n %s%n", actual, path); + } + } + } + + private static final class ValueProcessingFailed extends BasicErrorMessageFactory { + + private ValueProcessingFailed(String actualToString, String errorMessage) { + super("%nExpected:%n %s%n%s".formatted(actualToString, errorMessage)); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/json/AbstractJsonValueAssert.java b/spring-test/src/main/java/org/springframework/test/json/AbstractJsonValueAssert.java new file mode 100644 index 000000000000..ffa5409d9846 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/AbstractJsonValueAssert.java @@ -0,0 +1,234 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.json; + +import java.lang.reflect.Type; +import java.util.List; +import java.util.Map; + +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.api.AbstractBooleanAssert; +import org.assertj.core.api.AbstractMapAssert; +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.AbstractStringAssert; +import org.assertj.core.api.AssertFactory; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.assertj.core.api.ObjectArrayAssert; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.internal.Failures; + +import org.springframework.core.ResolvableType; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.test.http.HttpMessageContentConverter; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * Base AssertJ {@linkplain org.assertj.core.api.Assert assertions} that can be + * applied to a JSON value. + * + *

    In JSON, values must be one of the following data types: + *

      + *
    • a {@linkplain #asString() string}
    • + *
    • a {@linkplain #asNumber() number}
    • + *
    • a {@linkplain #asBoolean() boolean}
    • + *
    • an {@linkplain #asArray() array}
    • + *
    • an {@linkplain #asMap() object} (JSON object)
    • + *
    • {@linkplain #isNull() null}
    • + *
    + * This base class offers direct access for each of those types as well as + * conversion methods based on an optional {@link GenericHttpMessageConverter}. + * + * @author Stephane Nicoll + * @since 6.2 + * @param the type of assertions + */ +public abstract class AbstractJsonValueAssert> + extends AbstractObjectAssert { + + private final Failures failures = Failures.instance(); + + @Nullable + private final HttpMessageContentConverter contentConverter; + + + protected AbstractJsonValueAssert(@Nullable Object actual, Class selfType, + @Nullable HttpMessageContentConverter contentConverter) { + + super(actual, selfType); + this.contentConverter = contentConverter; + } + + + /** + * Verify that the actual value is a non-{@code null} {@link String}, + * and return a new {@linkplain AbstractStringAssert assertion} object that + * provides dedicated {@code String} assertions for it. + */ + @Override + public AbstractStringAssert asString() { + return Assertions.assertThat(castTo(String.class, "a string")); + } + + /** + * Verify that the actual value is a non-{@code null} {@link Number}, + * usually an {@link Integer} or {@link Double}, and return a new + * {@linkplain AbstractObjectAssert assertion} object for it. + */ + public AbstractObjectAssert asNumber() { + return Assertions.assertThat(castTo(Number.class, "a number")); + } + + /** + * Verify that the actual value is a non-{@code null} {@link Boolean}, + * and return a new {@linkplain AbstractBooleanAssert assertion} object + * that provides dedicated {@code Boolean} assertions for it. + */ + public AbstractBooleanAssert asBoolean() { + return Assertions.assertThat(castTo(Boolean.class, "a boolean")); + } + + /** + * Verify that the actual value is a non-{@code null} array, and return a + * new {@linkplain ObjectArrayAssert assertion} object that provides dedicated + * array assertions for it. + */ + public ObjectArrayAssert asArray() { + List list = castTo(List.class, "an array"); + Object[] array = list.toArray(new Object[0]); + return Assertions.assertThat(array); + } + + /** + * Verify that the actual value is a non-{@code null} JSON object, and + * return a new {@linkplain AbstractMapAssert assertion} object that + * provides dedicated assertions on individual elements of the + * object. + *

    The returned map assertion object uses attribute names as the keys, + * and the values can be any of the valid JSON values. + */ + @SuppressWarnings("unchecked") + public AbstractMapAssert, String, Object> asMap() { + return Assertions.assertThat(castTo(Map.class, "a map")); + } + + private T castTo(Class expectedType, String description) { + if (this.actual == null) { + throw valueProcessingFailed("To be %s%n".formatted(description)); + } + if (!expectedType.isInstance(this.actual)) { + throw valueProcessingFailed("To be %s%nBut was:%n %s%n".formatted(description, this.actual.getClass().getName())); + } + return expectedType.cast(this.actual); + } + + /** + * Verify that the actual value can be converted to an instance of the + * given {@code target}, and produce a new {@linkplain AbstractObjectAssert + * assertion} object narrowed to that type. + * @param target the {@linkplain Class type} to convert the actual value to + */ + public AbstractObjectAssert convertTo(Class target) { + isNotNull(); + T value = convertToTargetType(target); + return Assertions.assertThat(value); + } + + /** + * Verify that the actual value can be converted to an instance of the type + * defined by the given {@link AssertFactory} and return a new Assert narrowed + * to that type. + *

    {@link InstanceOfAssertFactories} provides static factories for all the + * types supported by {@link Assertions#assertThat}. Additional factories can + * be created by implementing {@link AssertFactory}. + *

    Example:

    
    +	 * // Check that the json value is an array of 3 users
    +	 * assertThat(jsonValue).convertTo(InstanceOfAssertFactories.list(User.class))
    +	 *         hasSize(3); // ListAssert of User
    +	 * 
    + * @param assertFactory the {@link AssertFactory} to use to produce a narrowed + * Assert for the type that it defines. + */ + public > ASSERT convertTo(AssertFactory assertFactory) { + isNotNull(); + return assertFactory.createAssert(this::convertToTargetType); + } + + /** + * Verify that the actual value is empty: either a {@code null} scalar value + * or an empty list or map. + *

    Can also be used when the path uses a filter operator to validate that + * it did not match. + */ + public SELF isEmpty() { + if (!ObjectUtils.isEmpty(this.actual)) { + throw valueProcessingFailed("To be empty"); + } + return this.myself; + } + + /** + * Verify that the actual value is not empty: either a non-{@code null} + * scalar value or a non-empty list or map. + *

    Can also be used when the path uses a filter operator to validate that + * it did match at least one element. + */ + public SELF isNotEmpty() { + if (ObjectUtils.isEmpty(this.actual)) { + throw valueProcessingFailed("To not be empty"); + } + return this.myself; + } + + private T convertToTargetType(Type targetType) { + if (this.contentConverter == null) { + throw new IllegalStateException( + "No JSON message converter available to convert %s".formatted(actualToString())); + } + try { + return this.contentConverter.convertViaJson(this.actual, ResolvableType.forType(targetType)); + } + catch (Exception ex) { + throw valueProcessingFailed("To convert successfully to:%n %s%nBut it failed:%n %s%n" + .formatted(targetType.getTypeName(), ex.getMessage())); + } + } + + protected String getExpectedErrorMessagePrefix() { + return "Expected:"; + } + + private AssertionError valueProcessingFailed(String errorMessage) { + throw this.failures.failure(this.info, new ValueProcessingFailed( + getExpectedErrorMessagePrefix(), actualToString(), errorMessage)); + } + + private String actualToString() { + return ObjectUtils.nullSafeToString(StringUtils.quoteIfString(this.actual)); + } + + + private static final class ValueProcessingFailed extends BasicErrorMessageFactory { + + private ValueProcessingFailed(String prefix, String actualToString, String errorMessage) { + super("%n%s%n %s%n%s".formatted(prefix, actualToString, errorMessage)); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/json/JsonAssert.java b/spring-test/src/main/java/org/springframework/test/json/JsonAssert.java new file mode 100644 index 000000000000..a8d2bf404aca --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/JsonAssert.java @@ -0,0 +1,103 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.json; + +import org.json.JSONException; +import org.skyscreamer.jsonassert.JSONCompare; +import org.skyscreamer.jsonassert.JSONCompareMode; +import org.skyscreamer.jsonassert.JSONCompareResult; +import org.skyscreamer.jsonassert.comparator.DefaultComparator; +import org.skyscreamer.jsonassert.comparator.JSONComparator; + +import org.springframework.lang.Nullable; + +/** + * Useful methods that can be used with {@code org.skyscreamer.jsonassert}. + * + * @author Phillip Webb + * @since 6.2 + */ +public abstract class JsonAssert { + + /** + * Create a {@link JsonComparator} from the given {@link JsonCompareMode}. + * @param compareMode the mode to use + * @return a new {@link JsonComparator} instance + * @see JSONCompareMode#STRICT + * @see JSONCompareMode#LENIENT + */ + public static JsonComparator comparator(JsonCompareMode compareMode) { + JSONCompareMode jsonAssertCompareMode = (compareMode != JsonCompareMode.LENIENT + ? JSONCompareMode.STRICT : JSONCompareMode.LENIENT); + return comparator(jsonAssertCompareMode); + } + + /** + * Create a new {@link JsonComparator} from the given JSONAssert + * {@link JSONComparator}. + * @param comparator the JSON Assert {@link JSONComparator} + * @return a new {@link JsonComparator} instance + */ + public static JsonComparator comparator(JSONComparator comparator) { + return new JsonAssertJsonComparator(comparator); + } + + /** + * Create a new {@link JsonComparator} from the given JSONAssert + * {@link JSONCompareMode}. + * @param mode the JSON Assert {@link JSONCompareMode} + * @return a new {@link JsonComparator} instance + */ + public static JsonComparator comparator(JSONCompareMode mode) { + return new JsonAssertJsonComparator(mode); + } + + private static class JsonAssertJsonComparator implements JsonComparator { + + private final JSONComparator jsonAssertComparator; + + JsonAssertJsonComparator(JSONComparator jsonAssertComparator) { + this.jsonAssertComparator = jsonAssertComparator; + } + + JsonAssertJsonComparator(JSONCompareMode compareMode) { + this(new DefaultComparator(compareMode)); + } + + @Override + public JsonComparison compare(@Nullable String expectedJson, @Nullable String actualJson) { + if (actualJson == null) { + return (expectedJson != null) + ? JsonComparison.mismatch("Expected null JSON") + : JsonComparison.match(); + } + if (expectedJson == null) { + return JsonComparison.mismatch("Expected non-null JSON"); + } + try { + JSONCompareResult result = JSONCompare.compareJSON(expectedJson, actualJson, this.jsonAssertComparator); + return (!result.passed()) + ? JsonComparison.mismatch(result.getMessage()) + : JsonComparison.match(); + } + catch (JSONException ex) { + throw new IllegalStateException(ex); + } + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/json/JsonComparator.java b/spring-test/src/main/java/org/springframework/test/json/JsonComparator.java new file mode 100644 index 000000000000..bc8dae2ee8ea --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/JsonComparator.java @@ -0,0 +1,54 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.json; + +import org.springframework.lang.Nullable; +import org.springframework.test.json.JsonComparison.Result; + +/** + * Strategy interface used to compare JSON strings. + * + * @author Phillip Webb + * @since 6.2 + * @see JsonAssert + */ +@FunctionalInterface +public interface JsonComparator { + + /** + * Compare the given JSON strings. + * @param expectedJson the expected JSON + * @param actualJson the actual JSON + * @return the JSON comparison + */ + JsonComparison compare(@Nullable String expectedJson, @Nullable String actualJson); + + /** + * Assert that the {@code expectedJson} matches the comparison rules of ths + * instance against the {@code actualJson}. Throw an {@link AssertionError} + * if the comparison does not match. + * @param expectedJson the expected JSON + * @param actualJson the actual JSON + */ + default void assertIsMatch(@Nullable String expectedJson, @Nullable String actualJson) { + JsonComparison comparison = compare(expectedJson, actualJson); + if (comparison.getResult() == Result.MISMATCH) { + throw new AssertionError(comparison.getMessage()); + } + } + +} diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationKey.java b/spring-test/src/main/java/org/springframework/test/json/JsonCompareMode.java similarity index 65% rename from spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationKey.java rename to spring-test/src/main/java/org/springframework/test/json/JsonCompareMode.java index cc23256f6c44..b82be904c856 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationKey.java +++ b/spring-test/src/main/java/org/springframework/test/json/JsonCompareMode.java @@ -14,15 +14,24 @@ * limitations under the License. */ -package org.springframework.beans.factory.aot; +package org.springframework.test.json; /** - * Record class holding key information for beans registered in a bean factory. + * Modes that can be used to compare JSON. * - * @author Brian Clozel - * @since 6.0.8 - * @param beanName the name of the registered bean - * @param beanClass the type of the registered bean + * @author Phillip Webb + * @since 6.2 */ -record BeanRegistrationKey(String beanName, Class beanClass) { +public enum JsonCompareMode { + + /** + * Strict checking. + */ + STRICT, + + /** + * Lenient checking. + */ + LENIENT + } diff --git a/spring-test/src/main/java/org/springframework/test/json/JsonComparison.java b/spring-test/src/main/java/org/springframework/test/json/JsonComparison.java new file mode 100644 index 000000000000..6930bc983335 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/JsonComparison.java @@ -0,0 +1,91 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.json; + +import org.springframework.lang.Nullable; + +/** + * A comparison of two JSON strings as returned from a {@link JsonComparator}. + * + * @author Phillip Webb + * @since 6.2 + */ +public final class JsonComparison { + + private final Result result; + + @Nullable + private final String message; + + + private JsonComparison(Result result, @Nullable String message) { + this.result = result; + this.message = message; + } + + /** + * Factory method to create a new {@link JsonComparison} when the JSON + * strings match. + * @return a new {@link JsonComparison} instance + */ + public static JsonComparison match() { + return new JsonComparison(Result.MATCH, null); + } + + /** + * Factory method to create a new {@link JsonComparison} when the JSON strings + * do not match. + * @param message a message describing the mismatch + * @return a new {@link JsonComparison} instance + */ + public static JsonComparison mismatch(String message) { + return new JsonComparison(Result.MISMATCH, message); + } + + /** + * Return the result of the comparison. + */ + public Result getResult() { + return this.result; + } + + /** + * Return a message describing the comparison. + */ + @Nullable + public String getMessage() { + return this.message; + } + + + /** + * Comparison results. + */ + public enum Result { + + /** + * The JSON strings match when considering the comparison rules. + */ + MATCH, + + /** + * The JSON strings do not match when considering the comparison rules. + */ + MISMATCH + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/json/JsonContent.java b/spring-test/src/main/java/org/springframework/test/json/JsonContent.java new file mode 100644 index 000000000000..28b02082e40b --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/JsonContent.java @@ -0,0 +1,91 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.json; + +import org.assertj.core.api.AssertProvider; + +import org.springframework.lang.Nullable; +import org.springframework.test.http.HttpMessageContentConverter; +import org.springframework.util.Assert; + +/** + * JSON content which is generally used to {@link AssertProvider provide} + * {@link JsonContentAssert} to AssertJ {@code assertThat} calls. + * + * @author Phillip Webb + * @author Diego Berrueta + * @since 6.2 + */ +public final class JsonContent implements AssertProvider { + + private final String json; + + @Nullable + private final HttpMessageContentConverter contentConverter; + + + /** + * Create a new {@code JsonContent} instance with the message converter to + * use to deserialize content. + * @param json the actual JSON content + * @param contentConverter the content converter to use + */ + public JsonContent(String json, @Nullable HttpMessageContentConverter contentConverter) { + Assert.notNull(json, "JSON must not be null"); + this.json = json; + this.contentConverter = contentConverter; + } + + /** + * Create a new {@code JsonContent} instance. + * @param json the actual JSON content + */ + public JsonContent(String json) { + this(json, null); + } + + + /** + * Use AssertJ's {@link org.assertj.core.api.Assertions#assertThat assertThat} + * instead. + */ + @Override + public JsonContentAssert assertThat() { + return new JsonContentAssert(this); + } + + /** + * Return the actual JSON content string. + */ + public String getJson() { + return this.json; + } + + /** + * Return the {@link HttpMessageContentConverter} to use to deserialize content. + */ + @Nullable + HttpMessageContentConverter getContentConverter() { + return this.contentConverter; + } + + @Override + public String toString() { + return "JsonContent " + this.json; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/json/JsonContentAssert.java b/spring-test/src/main/java/org/springframework/test/json/JsonContentAssert.java new file mode 100644 index 000000000000..9728a762496a --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/JsonContentAssert.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.json; + +import org.springframework.lang.Nullable; + +/** + * Default {@link AbstractJsonContentAssert} implementation. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class JsonContentAssert extends AbstractJsonContentAssert { + + /** + * Create an assert for the given JSON document. + * @param json the JSON document to assert + */ + public JsonContentAssert(@Nullable JsonContent json) { + super(json, JsonContentAssert.class); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/json/JsonLoader.java b/spring-test/src/main/java/org/springframework/test/json/JsonLoader.java new file mode 100644 index 000000000000..fe905c000a17 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/JsonLoader.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.json; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.lang.Nullable; +import org.springframework.util.FileCopyUtils; + +/** + * Internal helper used to load JSON from various sources. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Stephane Nicoll + * @since 6.2 + */ +class JsonLoader { + + @Nullable + private final Class resourceLoadClass; + + private final Charset charset; + + + JsonLoader(@Nullable Class resourceLoadClass, @Nullable Charset charset) { + this.resourceLoadClass = resourceLoadClass; + this.charset = (charset != null ? charset : StandardCharsets.UTF_8); + } + + + @Nullable + String getJson(@Nullable CharSequence source) { + if (source == null) { + return null; + } + String jsonSource = source.toString(); + if (jsonSource.endsWith(".json")) { + return getJson(new ClassPathResource(jsonSource, this.resourceLoadClass)); + } + return jsonSource; + } + + String getJson(Resource source) { + try { + return getJson(source.getInputStream()); + } + catch (IOException ex) { + throw new IllegalStateException("Unable to load JSON from " + source, ex); + } + } + + private String getJson(InputStream source) throws IOException { + return FileCopyUtils.copyToString(new InputStreamReader(source, this.charset)); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/json/JsonPathValueAssert.java b/spring-test/src/main/java/org/springframework/test/json/JsonPathValueAssert.java new file mode 100644 index 000000000000..c30843e87c57 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/JsonPathValueAssert.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.json; + +import com.jayway.jsonpath.JsonPath; + +import org.springframework.lang.Nullable; +import org.springframework.test.http.HttpMessageContentConverter; + +/** + * AssertJ {@linkplain org.assertj.core.api.Assert assertions} that can be applied + * to a JSON value produced by evaluating a {@linkplain JsonPath JSON path} + * expression. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class JsonPathValueAssert extends AbstractJsonValueAssert { + + private final String expression; + + + JsonPathValueAssert(@Nullable Object actual, String expression, + @Nullable HttpMessageContentConverter contentConverter) { + + super(actual, JsonPathValueAssert.class, contentConverter); + this.expression = expression; + } + + @Override + protected String getExpectedErrorMessagePrefix() { + return "Expected value at JSON path \"%s\":".formatted(this.expression); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/json/package-info.java b/spring-test/src/main/java/org/springframework/test/json/package-info.java new file mode 100644 index 000000000000..cf1085f3b403 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/json/package-info.java @@ -0,0 +1,9 @@ +/** + * Testing support for JSON. + */ +@NonNullApi +@NonNullFields +package org.springframework.test.json; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-test/src/main/java/org/springframework/test/util/AssertionErrors.java b/spring-test/src/main/java/org/springframework/test/util/AssertionErrors.java index 7c76c64599e2..db51fb160c0a 100644 --- a/spring-test/src/main/java/org/springframework/test/util/AssertionErrors.java +++ b/spring-test/src/main/java/org/springframework/test/util/AssertionErrors.java @@ -16,6 +16,7 @@ package org.springframework.test.util; +import org.springframework.lang.Contract; import org.springframework.lang.Nullable; import org.springframework.util.ObjectUtils; @@ -33,6 +34,7 @@ public abstract class AssertionErrors { * Fail a test with the given message. * @param message a message that describes the reason for the failure */ + @Contract("_ -> fail") public static void fail(String message) { throw new AssertionError(message); } @@ -65,6 +67,7 @@ public static void fail(String message, @Nullable Object expected, @Nullable Obj * @param message a message that describes the reason for the failure * @param condition the condition to test for */ + @Contract("_, false -> fail") public static void assertTrue(String message, boolean condition) { if (!condition) { fail(message); @@ -78,6 +81,7 @@ public static void assertTrue(String message, boolean condition) { * @param condition the condition to test for * @since 5.2.1 */ + @Contract("_, true -> fail") public static void assertFalse(String message, boolean condition) { if (condition) { fail(message); @@ -91,6 +95,7 @@ public static void assertFalse(String message, boolean condition) { * @param object the object to check * @since 5.2.1 */ + @Contract("_, !null -> fail") public static void assertNull(String message, @Nullable Object object) { assertTrue(message, object == null); } @@ -102,6 +107,7 @@ public static void assertNull(String message, @Nullable Object object) { * @param object the object to check * @since 5.1.8 */ + @Contract("_, null -> fail") public static void assertNotNull(String message, @Nullable Object object) { assertTrue(message, object != null); } diff --git a/spring-test/src/main/java/org/springframework/test/util/JsonExpectationsHelper.java b/spring-test/src/main/java/org/springframework/test/util/JsonExpectationsHelper.java index 7c6489a6da0d..a6b29e70ea5f 100644 --- a/spring-test/src/main/java/org/springframework/test/util/JsonExpectationsHelper.java +++ b/spring-test/src/main/java/org/springframework/test/util/JsonExpectationsHelper.java @@ -18,6 +18,8 @@ import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.test.json.JsonComparator; + /** * A helper class for assertions on JSON content. * @@ -26,7 +28,10 @@ * * @author Sebastien Deleuze * @since 4.1 + * @deprecated in favor of using {@link JSONAssert} directly or the + * {@link JsonComparator} abstraction */ +@Deprecated(since = "6.2") public class JsonExpectationsHelper { /** diff --git a/spring-test/src/main/java/org/springframework/test/util/JsonPathExpectationsHelper.java b/spring-test/src/main/java/org/springframework/test/util/JsonPathExpectationsHelper.java index 6bf523226e93..00771118bed5 100644 --- a/spring-test/src/main/java/org/springframework/test/util/JsonPathExpectationsHelper.java +++ b/spring-test/src/main/java/org/springframework/test/util/JsonPathExpectationsHelper.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author 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,14 +16,21 @@ package org.springframework.test.util; +import java.lang.reflect.Type; import java.util.List; import java.util.Map; +import java.util.function.Function; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.DocumentContext; import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.TypeRef; +import com.jayway.jsonpath.spi.mapper.MappingProvider; import org.hamcrest.CoreMatchers; import org.hamcrest.Matcher; import org.hamcrest.MatcherAssert; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -40,6 +47,7 @@ * @author Juergen Hoeller * @author Craig Andrews * @author Sam Brannen + * @author Stephane Nicoll * @since 3.2 */ public class JsonPathExpectationsHelper { @@ -48,17 +56,42 @@ public class JsonPathExpectationsHelper { private final JsonPath jsonPath; + private final Configuration configuration; + + /** + * Construct a new {@code JsonPathExpectationsHelper} using the + * {@linkplain Configuration#defaultConfiguration() default configuration}. + * @param expression the {@link JsonPath} expression; never {@code null} or empty + * @since 6.2 + */ + public JsonPathExpectationsHelper(String expression) { + this(expression, (Configuration) null); + } + + /** + * Construct a new {@code JsonPathExpectationsHelper}. + * @param expression the {@link JsonPath} expression; never {@code null} or empty + * @param configuration the {@link Configuration} to use or {@code null} to use the + * {@linkplain Configuration#defaultConfiguration() default configuration} + * @since 6.2 + */ + public JsonPathExpectationsHelper(String expression, @Nullable Configuration configuration) { + Assert.hasText(expression, "expression must not be null or empty"); + this.expression = expression; + this.jsonPath = JsonPath.compile(this.expression); + this.configuration = (configuration != null) ? configuration : Configuration.defaultConfiguration(); + } /** * Construct a new {@code JsonPathExpectationsHelper}. * @param expression the {@link JsonPath} expression; never {@code null} or empty * @param args arguments to parameterize the {@code JsonPath} expression with, * using formatting specifiers defined in {@link String#format(String, Object...)} + * @deprecated in favor of calling {@link String#formatted(Object...)} upfront */ + @Deprecated(since = "6.2", forRemoval = true) public JsonPathExpectationsHelper(String expression, Object... args) { - Assert.hasText(expression, "expression must not be null or empty"); - this.expression = String.format(expression, args); - this.jsonPath = JsonPath.compile(this.expression); + this(expression.formatted(args), (Configuration) null); } @@ -83,9 +116,25 @@ public void assertValue(String content, Matcher matcher) { * @param targetType the expected type of the resulting value * @since 4.3.3 */ - @SuppressWarnings("unchecked") public void assertValue(String content, Matcher matcher, Class targetType) { - T value = (T) evaluateJsonPath(content, targetType); + T value = evaluateJsonPath(content, targetType); + MatcherAssert.assertThat("JSON path \"" + this.expression + "\"", value, matcher); + } + + /** + * An overloaded variant of {@link #assertValue(String, Matcher)} that also + * accepts a target type for the resulting value that allows generic types + * to be defined. + *

    This must be used with a {@link Configuration} that defines a more + * elaborate {@link MappingProvider} as the default one cannot handle + * generic types. + * @param content the JSON content + * @param matcher the matcher with which to assert the result + * @param targetType the expected type of the resulting value + * @since 6.2 + */ + public void assertValue(String content, Matcher matcher, ParameterizedTypeReference targetType) { + T value = evaluateJsonPath(content, targetType); MatcherAssert.assertThat("JSON path \"" + this.expression + "\"", value, matcher); } @@ -296,7 +345,7 @@ private String failureReason(String expectedDescription, @Nullable Object value) @Nullable public Object evaluateJsonPath(String content) { try { - return this.jsonPath.read(content); + return this.jsonPath.read(content, this.configuration); } catch (Throwable ex) { throw new AssertionError("No value at JSON path \"" + this.expression + "\"", ex); @@ -306,19 +355,32 @@ public Object evaluateJsonPath(String content) { /** * Variant of {@link #evaluateJsonPath(String)} with a target type. *

    This can be useful for matching numbers reliably for example coercing an - * integer into a double. + * integer into a double or when the configured {@link MappingProvider} can + * handle more complex object structures. * @param content the content to evaluate against + * @param targetType the requested target type * @return the result of the evaluation * @throws AssertionError if the evaluation fails */ - public Object evaluateJsonPath(String content, Class targetType) { - try { - return JsonPath.parse(content).read(this.expression, targetType); - } - catch (Throwable ex) { - String message = "No value at JSON path \"" + this.expression + "\""; - throw new AssertionError(message, ex); - } + public T evaluateJsonPath(String content, Class targetType) { + return evaluateExpression(content, context -> context.read(this.expression, targetType)); + } + + /** + * Variant of {@link #evaluateJsonPath(String)} with a target type that has + * generics. + *

    This must be used with a {@link Configuration} that defines a more + * elaborate {@link MappingProvider} as the default one cannot handle + * generic types. + * @param content the content to evaluate against + * @param targetType the requested target type + * @return the result of the evaluation + * @throws AssertionError if the evaluation fails + * @since 6.2 + */ + public T evaluateJsonPath(String content, ParameterizedTypeReference targetType) { + return evaluateExpression(content, context -> + context.read(this.expression, new TypeRefAdapter<>(targetType))); } @Nullable @@ -336,4 +398,33 @@ private boolean pathIsIndefinite() { return !this.jsonPath.isDefinite(); } + private T evaluateExpression(String content, Function action) { + try { + DocumentContext context = JsonPath.parse(content, this.configuration); + return action.apply(context); + } + catch (Throwable ex) { + String message = "Failed to evaluate JSON path \"" + this.expression + "\""; + throw new AssertionError(message, ex); + } + } + + + /** + * Adapt JSONPath {@link TypeRef} to {@link ParameterizedTypeReference}. + */ + private static final class TypeRefAdapter extends TypeRef { + + private final Type type; + + TypeRefAdapter(ParameterizedTypeReference typeReference) { + this.type = typeReference.getType(); + } + + @Override + public Type getType() { + return this.type; + } + } + } diff --git a/spring-test/src/main/java/org/springframework/test/util/MethodAssert.java b/spring-test/src/main/java/org/springframework/test/util/MethodAssert.java new file mode 100644 index 000000000000..a346aa6a548f --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/util/MethodAssert.java @@ -0,0 +1,62 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.util; + +import java.lang.reflect.Method; + +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.Assertions; + +import org.springframework.lang.Nullable; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied + * to a {@link Method}. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class MethodAssert extends AbstractObjectAssert { + + public MethodAssert(@Nullable Method actual) { + super(actual, MethodAssert.class); + as("Method %s", actual); + } + + /** + * Verify that the actual method has the given {@linkplain Method#getName() + * name}. + * @param name the expected method name + */ + public MethodAssert hasName(String name) { + isNotNull(); + Assertions.assertThat(this.actual.getName()).as("Method name").isEqualTo(name); + return this.myself; + } + + /** + * Verify that the actual method is declared in the given {@code type}. + * @param type the expected declaring class + */ + public MethodAssert hasDeclaringClass(Class type) { + isNotNull(); + Assertions.assertThat(this.actual.getDeclaringClass()) + .as("Method declaring class").isEqualTo(type); + return this.myself; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/util/ReflectionTestUtils.java b/spring-test/src/main/java/org/springframework/test/util/ReflectionTestUtils.java index 419374b95767..f63738405e9f 100644 --- a/spring-test/src/main/java/org/springframework/test/util/ReflectionTestUtils.java +++ b/spring-test/src/main/java/org/springframework/test/util/ReflectionTestUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2024 the original author 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.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.aop.support.AopUtils; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -172,6 +173,7 @@ public static void setField( * @see ReflectionUtils#setField(Field, Object, Object) * @see AopTestUtils#getUltimateTargetObject(Object) */ + @SuppressWarnings("NullAway") public static void setField(@Nullable Object targetObject, @Nullable Class targetClass, @Nullable String name, @Nullable Object value, @Nullable Class type) { @@ -259,6 +261,7 @@ public static Object getField(Class targetClass, String name) { * @see AopTestUtils#getUltimateTargetObject(Object) */ @Nullable + @SuppressWarnings("NullAway") public static Object getField(@Nullable Object targetObject, @Nullable Class targetClass, String name) { Assert.isTrue(targetObject != null || targetClass != null, "Either targetObject or targetClass for the field must be specified"); @@ -287,22 +290,14 @@ public static Object getField(@Nullable Object targetObject, @Nullable Class /** * Invoke the setter method with the given {@code name} on the supplied * target object with the supplied {@code value}. - *

    This method traverses the class hierarchy in search of the desired - * method. In addition, an attempt will be made to make non-{@code public} - * methods accessible, thus allowing one to invoke {@code protected}, - * {@code private}, and package-private setter methods. - *

    In addition, this method supports JavaBean-style property - * names. For example, if you wish to set the {@code name} property on the - * target object, you may pass either "name" or - * "setName" as the method name. + *

    This method delegates to + * {@link #invokeSetterMethod(Object, String, Object, Class)}, supplying + * {@code null} for the parameter type. * @param target the target object on which to invoke the specified setter * method * @param name the name of the setter method to invoke or the corresponding * property name * @param value the value to provide to the setter method - * @see ReflectionUtils#findMethod(Class, String, Class[]) - * @see ReflectionUtils#makeAccessible(Method) - * @see ReflectionUtils#invokeMethod(Method, Object, Object[]) */ public static void invokeSetterMethod(Object target, String name, Object value) { invokeSetterMethod(target, name, value, null); @@ -315,19 +310,24 @@ public static void invokeSetterMethod(Object target, String name, Object value) * method. In addition, an attempt will be made to make non-{@code public} * methods accessible, thus allowing one to invoke {@code protected}, * {@code private}, and package-private setter methods. - *

    In addition, this method supports JavaBean-style property - * names. For example, if you wish to set the {@code name} property on the - * target object, you may pass either "name" or - * "setName" as the method name. + *

    This method also supports JavaBean-style property names. For + * example, if you wish to set the {@code name} property on the target object, + * you may pass either {@code "name"} or {@code "setName"} as the method name. + *

    As of Spring Framework 6.2, if the supplied target object is a CGLIB + * proxy which does not intercept the setter method, the proxy will be + * {@linkplain AopTestUtils#getUltimateTargetObject unwrapped} allowing the + * setter method to be invoked directly on the ultimate target of the proxy. * @param target the target object on which to invoke the specified setter * method * @param name the name of the setter method to invoke or the corresponding * property name * @param value the value to provide to the setter method * @param type the formal parameter type declared by the setter method + * (may be {@code null} to indicate any type) * @see ReflectionUtils#findMethod(Class, String, Class[]) * @see ReflectionUtils#makeAccessible(Method) * @see ReflectionUtils#invokeMethod(Method, Object, Object[]) + * @see AopTestUtils#getUltimateTargetObject(Object) */ public static void invokeSetterMethod(Object target, String name, @Nullable Object value, @Nullable Class type) { Assert.notNull(target, "Target object must not be null"); @@ -355,6 +355,14 @@ public static void invokeSetterMethod(Object target, String name, @Nullable Obje safeToString(target), value)); } + if (springAopPresent) { + // If the target is a CGLIB proxy which does not intercept the method, invoke the + // method on the ultimate target. + if (isCglibProxyThatDoesNotInterceptMethod(target, method)) { + target = AopTestUtils.getUltimateTargetObject(target); + } + } + ReflectionUtils.makeAccessible(method); ReflectionUtils.invokeMethod(method, target, value); } @@ -366,10 +374,13 @@ public static void invokeSetterMethod(Object target, String name, @Nullable Obje * method. In addition, an attempt will be made to make non-{@code public} * methods accessible, thus allowing one to invoke {@code protected}, * {@code private}, and package-private getter methods. - *

    In addition, this method supports JavaBean-style property - * names. For example, if you wish to get the {@code name} property on the - * target object, you may pass either "name" or - * "getName" as the method name. + *

    This method also supports JavaBean-style property names. For + * example, if you wish to get the {@code name} property on the target object, + * you may pass either {@code "name"} or {@code "getName"} as the method name. + *

    As of Spring Framework 6.2, if the supplied target object is a CGLIB + * proxy which does not intercept the getter method, the proxy will be + * {@linkplain AopTestUtils#getUltimateTargetObject unwrapped} allowing the + * getter method to be invoked directly on the ultimate target of the proxy. * @param target the target object on which to invoke the specified getter * method * @param name the name of the getter method to invoke or the corresponding @@ -378,6 +389,7 @@ public static void invokeSetterMethod(Object target, String name, @Nullable Obje * @see ReflectionUtils#findMethod(Class, String, Class[]) * @see ReflectionUtils#makeAccessible(Method) * @see ReflectionUtils#invokeMethod(Method, Object, Object[]) + * @see AopTestUtils#getUltimateTargetObject(Object) */ @Nullable public static Object invokeGetterMethod(Object target, String name) { @@ -398,6 +410,14 @@ public static Object invokeGetterMethod(Object target, String name) { "Could not find getter method '%s' on %s", getterMethodName, safeToString(target))); } + if (springAopPresent) { + // If the target is a CGLIB proxy which does not intercept the method, invoke the + // method on the ultimate target. + if (isCglibProxyThatDoesNotInterceptMethod(target, method)) { + target = AopTestUtils.getUltimateTargetObject(target); + } + } + if (logger.isDebugEnabled()) { logger.debug(String.format("Invoking getter method '%s' on %s", getterMethodName, safeToString(target))); } @@ -416,10 +436,6 @@ public static Object invokeGetterMethod(Object target, String name) { * @return the invocation result, if any * @see #invokeMethod(Class, String, Object...) * @see #invokeMethod(Object, Class, String, Object...) - * @see MethodInvoker - * @see ReflectionUtils#makeAccessible(Method) - * @see ReflectionUtils#invokeMethod(Method, Object, Object[]) - * @see ReflectionUtils#handleReflectionException(Exception) */ @Nullable public static T invokeMethod(Object target, String name, Object... args) { @@ -439,10 +455,6 @@ public static T invokeMethod(Object target, String name, Object... args) { * @since 5.2 * @see #invokeMethod(Object, String, Object...) * @see #invokeMethod(Object, Class, String, Object...) - * @see MethodInvoker - * @see ReflectionUtils#makeAccessible(Method) - * @see ReflectionUtils#invokeMethod(Method, Object, Object[]) - * @see ReflectionUtils#handleReflectionException(Exception) */ @Nullable public static T invokeMethod(Class targetClass, String name, Object... args) { @@ -457,6 +469,10 @@ public static T invokeMethod(Class targetClass, String name, Object... ar * method. In addition, an attempt will be made to make non-{@code public} * methods accessible, thus allowing one to invoke {@code protected}, * {@code private}, and package-private methods. + *

    As of Spring Framework 6.2, if the supplied target object is a CGLIB + * proxy which does not intercept the method, the proxy will be + * {@linkplain AopTestUtils#getUltimateTargetObject unwrapped} allowing the + * method to be invoked directly on the ultimate target of the proxy. * @param targetObject the target object on which to invoke the method; may * be {@code null} if the method is static * @param targetClass the target class on which to invoke the method; may @@ -469,8 +485,8 @@ public static T invokeMethod(Class targetClass, String name, Object... ar * @see #invokeMethod(Class, String, Object...) * @see MethodInvoker * @see ReflectionUtils#makeAccessible(Method) - * @see ReflectionUtils#invokeMethod(Method, Object, Object[]) * @see ReflectionUtils#handleReflectionException(Exception) + * @see AopTestUtils#getUltimateTargetObject(Object) */ @SuppressWarnings("unchecked") @Nullable @@ -491,6 +507,15 @@ public static T invokeMethod(@Nullable Object targetObject, @Nullable Class< methodInvoker.setArguments(args); methodInvoker.prepare(); + if (targetObject != null && springAopPresent) { + // If the target is a CGLIB proxy which does not intercept the method, invoke the + // method on the ultimate target. + if (isCglibProxyThatDoesNotInterceptMethod(targetObject, methodInvoker.getPreparedMethod())) { + targetObject = AopTestUtils.getUltimateTargetObject(targetObject); + methodInvoker.setTargetObject(targetObject); + } + } + if (logger.isDebugEnabled()) { logger.debug(String.format("Invoking method '%s' on %s or %s with arguments %s", name, safeToString(targetObject), safeToString(targetClass), ObjectUtils.nullSafeToString(args))); @@ -518,4 +543,13 @@ private static String safeToString(@Nullable Class clazz) { return String.format("target class [%s]", (clazz != null ? clazz.getName() : null)); } + /** + * Determine if the supplied target object is a CBLIB proxy that does not intercept the + * supplied method. + * @since 6.2 + */ + private static boolean isCglibProxyThatDoesNotInterceptMethod(Object target, Method method) { + return (AopUtils.isCglibProxy(target) && !method.getDeclaringClass().equals(target.getClass())); + } + } diff --git a/spring-test/src/main/java/org/springframework/test/util/XmlExpectationsHelper.java b/spring-test/src/main/java/org/springframework/test/util/XmlExpectationsHelper.java index 255d307fd80f..5468b7f9bcd1 100644 --- a/spring-test/src/main/java/org/springframework/test/util/XmlExpectationsHelper.java +++ b/spring-test/src/main/java/org/springframework/test/util/XmlExpectationsHelper.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ public boolean hasDifferences() { @Override public String toString() { - return this.diff.toString(); + return this.diff.fullDescription(); } } diff --git a/spring-test/src/main/java/org/springframework/test/util/XpathExpectationsHelper.java b/spring-test/src/main/java/org/springframework/test/util/XpathExpectationsHelper.java index 026d0527f9f2..046c0d406f22 100644 --- a/spring-test/src/main/java/org/springframework/test/util/XpathExpectationsHelper.java +++ b/spring-test/src/main/java/org/springframework/test/util/XpathExpectationsHelper.java @@ -223,7 +223,7 @@ public T evaluateXpath(byte[] content, @Nullable String encoding, Class t /** * Parse the given XML content to a {@link Document}. * @param xml the content to parse - * @param encoding optional content encoding, if provided as metadata (e.g. in HTTP headers) + * @param encoding optional content encoding, if provided as metadata (for example, in HTTP headers) * @return the parsed document */ protected Document parseXmlByteArray(byte[] xml, @Nullable String encoding) throws Exception { diff --git a/spring-test/src/main/java/org/springframework/test/validation/AbstractBindingResultAssert.java b/spring-test/src/main/java/org/springframework/test/validation/AbstractBindingResultAssert.java new file mode 100644 index 000000000000..cff017d09ab1 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/validation/AbstractBindingResultAssert.java @@ -0,0 +1,124 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.validation; + +import java.util.List; + +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.api.AssertProvider; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.ListAssert; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.internal.Failures; + +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to + * {@link BindingResult}. + * + * @author Stephane Nicoll + * @since 6.2 + * @param the type of assertions + */ +public abstract class AbstractBindingResultAssert> extends AbstractAssert { + + private final Failures failures = Failures.instance(); + + private final String name; + + + protected AbstractBindingResultAssert(String name, BindingResult bindingResult, Class selfType) { + super(bindingResult, selfType); + this.name = name; + as("Binding result for attribute '%s", this.name); + } + + /** + * Verify that the total number of errors is equal to the expected value. + * @param expected the expected number of errors + */ + public SELF hasErrorsCount(int expected) { + assertThat(this.actual.getErrorCount()) + .as("check errors for attribute '%s'", this.name).isEqualTo(expected); + return this.myself; + } + + /** + * Verify that the actual binding result contains fields in error with the + * given {@code fieldNames}. + * @param fieldNames the names of fields that should be in error + */ + public SELF hasFieldErrors(String... fieldNames) { + assertThat(fieldErrorNames()).contains(fieldNames); + return this.myself; + } + + /** + * Verify that the actual binding result contains only fields in + * error with the given {@code fieldNames}, and nothing else. + * @param fieldNames the exhaustive list of field names that should be in error + */ + public SELF hasOnlyFieldErrors(String... fieldNames) { + assertThat(fieldErrorNames()).containsOnly(fieldNames); + return this.myself; + } + + /** + * Verify that the field with the given {@code fieldName} has an error + * matching the given {@code errorCode}. + * @param fieldName the name of a field in error + * @param errorCode the error code for that field + */ + public SELF hasFieldErrorCode(String fieldName, String errorCode) { + Assertions.assertThat(getFieldError(fieldName).getCode()) + .as("check error code for field '%s'", fieldName).isEqualTo(errorCode); + return this.myself; + } + + protected AssertionError unexpectedBindingResult(String reason, Object... arguments) { + return this.failures.failure(this.info, new UnexpectedBindingResult(reason, arguments)); + } + + private AssertProvider> fieldErrorNames() { + return () -> { + List actual = this.actual.getFieldErrors().stream().map(FieldError::getField).toList(); + return new ListAssert<>(actual).as("check field errors"); + }; + } + + private FieldError getFieldError(String fieldName) { + FieldError fieldError = this.actual.getFieldError(fieldName); + if (fieldError == null) { + throw unexpectedBindingResult("to have at least an error for field '%s'", fieldName); + } + return fieldError; + } + + + private final class UnexpectedBindingResult extends BasicErrorMessageFactory { + + private UnexpectedBindingResult(String reason, Object... arguments) { + super("%nExpecting binding result:%n %s%n%s", AbstractBindingResultAssert.this.actual, + reason.formatted(arguments)); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/validation/package-info.java b/spring-test/src/main/java/org/springframework/test/validation/package-info.java new file mode 100644 index 000000000000..caa3fdcadda3 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/validation/package-info.java @@ -0,0 +1,9 @@ +/** + * Testing support for validation. + */ +@NonNullApi +@NonNullFields +package org.springframework.test.validation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-test/src/main/java/org/springframework/test/web/ModelAndViewAssert.java b/spring-test/src/main/java/org/springframework/test/web/ModelAndViewAssert.java index 02653350eb5a..0d87fdfe257c 100644 --- a/spring-test/src/main/java/org/springframework/test/web/ModelAndViewAssert.java +++ b/spring-test/src/main/java/org/springframework/test/web/ModelAndViewAssert.java @@ -53,7 +53,7 @@ public abstract class ModelAndViewAssert { * @param expectedType expected type of the model value * @return the model value */ - @SuppressWarnings("unchecked") + @SuppressWarnings({"unchecked", "NullAway"}) public static T assertAndReturnModelAttributeOfType(ModelAndView mav, String modelName, Class expectedType) { Map model = mav.getModel(); Object obj = model.get(modelName); @@ -109,6 +109,7 @@ public static void assertModelAttributeValue(ModelAndView mav, String modelName, * @param mav the ModelAndView to test against (never {@code null}) * @param expectedModel the expected model */ + @SuppressWarnings("NullAway") public static void assertModelAttributeValues(ModelAndView mav, Map expectedModel) { Map model = mav.getModel(); diff --git a/spring-test/src/main/java/org/springframework/test/web/UriAssert.java b/spring-test/src/main/java/org/springframework/test/web/UriAssert.java new file mode 100644 index 000000000000..7fac1976fbb3 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/UriAssert.java @@ -0,0 +1,103 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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; + +import org.assertj.core.api.AbstractStringAssert; +import org.assertj.core.api.Assertions; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.internal.Failures; + +import org.springframework.lang.Nullable; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied + * to a {@link String} representing a URI. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class UriAssert extends AbstractStringAssert { + + private static final AntPathMatcher pathMatcher = new AntPathMatcher(); + + private final String displayName; + + public UriAssert(@Nullable String actual, String displayName) { + super(actual, UriAssert.class); + this.displayName = displayName; + as(displayName); + } + + /** + * Verify that the actual URI is equal to the URI built using the given + * {@code uriTemplate} and {@code uriVars}. + * Example:

    
    +	 * // Verify that uri is equal to "/orders/1/items/2"
    +	 * assertThat(uri).isEqualToTemplate("/orders/{orderId}/items/{itemId}", 1, 2));
    +	 * 
    + * @param uriTemplate the expected URI string, with a number of URI + * template variables + * @param uriVars the values to replace the URI template variables + * @see UriComponentsBuilder#buildAndExpand(Object...) + */ + public UriAssert isEqualToTemplate(String uriTemplate, Object... uriVars) { + String uri = buildUri(uriTemplate, uriVars); + return isEqualTo(uri); + } + + /** + * Verify that the actual URI matches the given {@linkplain AntPathMatcher + * Ant-style} {@code uriPattern}. + * Example:
    
    +	 * // Verify that pattern matches "/orders/1/items/2"
    +	 * assertThat(uri).matchPattern("/orders/*"));
    +	 * 
    + * @param uriPattern the pattern that is expected to match + * @see AntPathMatcher + */ + public UriAssert matchesAntPattern(String uriPattern) { + Assertions.assertThat(pathMatcher.isPattern(uriPattern)) + .withFailMessage("'%s' is not an Ant-style path pattern", uriPattern).isTrue(); + Assertions.assertThat(pathMatcher.match(uriPattern, this.actual)) + .withFailMessage("%s '%s' does not match the expected URI pattern '%s'", + this.displayName, this.actual, uriPattern).isTrue(); + return this; + } + + @SuppressWarnings("NullAway") + private String buildUri(String uriTemplate, Object... uriVars) { + try { + return UriComponentsBuilder.fromUriString(uriTemplate) + .buildAndExpand(uriVars).encode().toUriString(); + } + catch (Exception ex) { + throw Failures.instance().failure(this.info, + new ShouldBeValidUriTemplate(uriTemplate, ex.getMessage())); + } + } + + + private static final class ShouldBeValidUriTemplate extends BasicErrorMessageFactory { + + private ShouldBeValidUriTemplate(String uriTemplate, String errorMessage) { + super("%nExpecting:%n %s%nTo be a valid URI template but got:%n %s%n", uriTemplate, errorMessage); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java b/spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java index d99d98f28f63..de937e6051bd 100644 --- a/spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,8 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @@ -40,7 +42,10 @@ import org.springframework.lang.Nullable; import org.springframework.mock.http.MockHttpInputMessage; import org.springframework.mock.http.client.MockClientHttpRequest; -import org.springframework.test.util.JsonExpectationsHelper; +import org.springframework.test.json.JsonAssert; +import org.springframework.test.json.JsonComparator; +import org.springframework.test.json.JsonCompareMode; +import org.springframework.test.json.JsonComparison; import org.springframework.test.util.XmlExpectationsHelper; import org.springframework.test.web.client.RequestMatcher; import org.springframework.util.LinkedMultiValueMap; @@ -60,9 +65,14 @@ */ public class ContentRequestMatchers { - private final XmlExpectationsHelper xmlHelper; + /** + * The encoding for parsing multipart content when the sender hasn't specified it. + * @see DiskFileItemFactory#setDefaultCharset(String) + */ + private static final Charset DEFAULT_MULTIPART_ENCODING = StandardCharsets.UTF_8; - private final JsonExpectationsHelper jsonHelper; + + private final XmlExpectationsHelper xmlHelper; /** @@ -71,7 +81,6 @@ public class ContentRequestMatchers { */ protected ContentRequestMatchers() { this.xmlHelper = new XmlExpectationsHelper(); - this.jsonHelper = new JsonExpectationsHelper(); } @@ -204,7 +213,15 @@ private RequestMatcher formData(MultiValueMap expectedMap, boole * @since 5.3 */ public RequestMatcher multipartData(MultiValueMap expectedMap) { - return multipartData(expectedMap, true); + return multipartData(expectedMap, DEFAULT_MULTIPART_ENCODING, true); + } + + /** + * Variant of {@link #multipartData(MultiValueMap)} with a defaultCharset. + * @since 6.2 + */ + public RequestMatcher multipartData(MultiValueMap expectedMap, Charset defaultCharset) { + return multipartData(expectedMap, defaultCharset, true); } /** @@ -219,13 +236,15 @@ public RequestMatcher multipartData(MultiValueMap expectedMap) { public RequestMatcher multipartDataContains(Map expectedMap) { MultiValueMap map = new LinkedMultiValueMap<>(expectedMap.size()); expectedMap.forEach(map::add); - return multipartData(map, false); + return multipartData(map, DEFAULT_MULTIPART_ENCODING, false); } @SuppressWarnings("ConstantConditions") - private RequestMatcher multipartData(MultiValueMap expectedMap, boolean containsExactly) { + private RequestMatcher multipartData( + MultiValueMap expectedMap, Charset defaultCharset, boolean containsExactly) { + return request -> { - MultiValueMap actualMap = MultipartHelper.parse((MockClientHttpRequest) request); + MultiValueMap actualMap = MultipartHelper.parse((MockClientHttpRequest) request, defaultCharset); if (containsExactly) { assertEquals("Multipart request content: " + actualMap, expectedMap.size(), actualMap.size()); } @@ -311,7 +330,7 @@ protected void matchInternal(MockClientHttpRequest request) throws Exception { * @since 5.0.5 */ public RequestMatcher json(String expectedJsonContent) { - return json(expectedJsonContent, false); + return json(expectedJsonContent, JsonCompareMode.LENIENT); } /** @@ -328,12 +347,43 @@ public RequestMatcher json(String expectedJsonContent) { * @param expectedJsonContent the expected JSON content * @param strict enables strict checking * @since 5.0.5 + * @deprecated in favor of {@link #json(String, JsonCompareMode)} */ + @Deprecated(since = "6.2") public RequestMatcher json(String expectedJsonContent, boolean strict) { + JsonCompareMode compareMode = (strict ? JsonCompareMode.STRICT : JsonCompareMode.LENIENT); + return json(expectedJsonContent, compareMode); + } + + /** + * Parse the request body and the given string as JSON and assert the two + * using the given {@linkplain JsonCompareMode mode}. If the comparison failed, + * throws an {@link AssertionError} with the message of the {@link JsonComparison}. + *

    Use of this matcher requires the JSONassert library. + * @param expectedJsonContent the expected JSON content + * @param compareMode the compare mode + * @since 6.2 + */ + public RequestMatcher json(String expectedJsonContent, JsonCompareMode compareMode) { + return json(expectedJsonContent, JsonAssert.comparator(compareMode)); + } + + /** + * Parse the request body and the given string as JSON and assert the two + * using the given {@link JsonComparator}. If the comparison failed, throws an + * {@link AssertionError} with the message of the {@link JsonComparison}. + *

    Use this matcher if you require a custom JSONAssert configuration or + * if you desire to use another assertion library. + * @param expectedJsonContent the expected JSON content + * @param comparator the comparator to use + * @since 6.2 + */ + public RequestMatcher json(String expectedJsonContent, JsonComparator comparator) { return request -> { try { MockClientHttpRequest mockRequest = (MockClientHttpRequest) request; - this.jsonHelper.assertJsonEqual(expectedJsonContent, mockRequest.getBodyAsString(), strict); + comparator.assertIsMatch(expectedJsonContent, mockRequest.getBodyAsString()); } catch (Exception ex) { throw new AssertionError("Failed to parse expected or actual JSON request content", ex); @@ -364,10 +414,12 @@ public final void match(ClientHttpRequest request) throws IOException, Assertion private static class MultipartHelper { - public static MultiValueMap parse(MockClientHttpRequest request) { + public static MultiValueMap parse(MockClientHttpRequest request, Charset defaultCharset) { try { FileUpload fileUpload = new FileUpload(); - fileUpload.setFileItemFactory(new DiskFileItemFactory()); + DiskFileItemFactory factory = new DiskFileItemFactory(); + factory.setDefaultCharset(defaultCharset.name()); + fileUpload.setFileItemFactory(factory); List fileItems = fileUpload.parseRequest(new UploadContext() { private final byte[] body = request.getBodyAsBytes(); diff --git a/spring-test/src/main/java/org/springframework/test/web/client/match/JsonPathRequestMatchers.java b/spring-test/src/main/java/org/springframework/test/web/client/match/JsonPathRequestMatchers.java index 43714408aa53..39e1d4bd57b1 100644 --- a/spring-test/src/main/java/org/springframework/test/web/client/match/JsonPathRequestMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/client/match/JsonPathRequestMatchers.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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.springframework.mock.http.client.MockClientHttpRequest; import org.springframework.test.util.JsonPathExpectationsHelper; import org.springframework.test.web.client.RequestMatcher; +import org.springframework.util.Assert; /** * Factory for assertions on the request content using @@ -53,7 +54,8 @@ public class JsonPathRequestMatchers { * using formatting specifiers defined in {@link String#format(String, Object...)} */ protected JsonPathRequestMatchers(String expression, Object... args) { - this.jsonPathHelper = new JsonPathExpectationsHelper(expression, args); + Assert.hasText(expression, "expression must not be null or empty"); + this.jsonPathHelper = new JsonPathExpectationsHelper(expression.formatted(args)); } diff --git a/spring-test/src/main/java/org/springframework/test/web/client/match/MockRestRequestMatchers.java b/spring-test/src/main/java/org/springframework/test/web/client/match/MockRestRequestMatchers.java index efa97c27194c..203b4a853827 100644 --- a/spring-test/src/main/java/org/springframework/test/web/client/match/MockRestRequestMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/client/match/MockRestRequestMatchers.java @@ -158,6 +158,7 @@ public static RequestMatcher queryParamList(String name, Matcher... matchers) { return request -> { MultiValueMap params = getQueryParams(request); @@ -185,6 +186,7 @@ public static RequestMatcher queryParam(String name, Matcher... * @see #queryParamList(String, Matcher) * @see #queryParam(String, Matcher...) */ + @SuppressWarnings("NullAway") public static RequestMatcher queryParam(String name, String... expectedValues) { return request -> { MultiValueMap params = getQueryParams(request); @@ -362,7 +364,7 @@ private static void assertValueCount( if (values == null) { fail(message + " to exist but was null"); } - if (count > values.size()) { + else if (count > values.size()) { fail(message + " to have at least <" + count + "> values but found " + values); } } 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 f9804915f72b..df5bdbd46ef5 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-2021 the original author or authors. + * Copyright 2002-2024 the original author 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,16 +17,16 @@ package org.springframework.test.web.reactive.server; import java.time.Duration; -import java.util.Objects; import java.util.function.Consumer; import org.hamcrest.Matcher; import org.hamcrest.MatcherAssert; import org.springframework.http.ResponseCookie; -import org.springframework.test.util.AssertionErrors; import static org.hamcrest.MatcherAssert.assertThat; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.fail; /** * Assertions on cookies of the response. @@ -48,18 +48,20 @@ public CookieAssertions(ExchangeResult exchangeResult, WebTestClient.ResponseSpe /** - * Expect a header with the given name to match the specified values. + * Expect a response cookie with the given name to match the specified value. */ public WebTestClient.ResponseSpec valueEquals(String name, String value) { + String cookieValue = getCookie(name).getValue(); this.exchangeResult.assertWithDiagnostics(() -> { String message = getMessage(name); - AssertionErrors.assertEquals(message, value, getCookie(name).getValue()); + assertEquals(message, value, cookieValue); }); return this.responseSpec; } /** - * Assert the first value of the response cookie with a Hamcrest {@link Matcher}. + * Assert the value of the response cookie with the given name with a Hamcrest + * {@link Matcher}. */ public WebTestClient.ResponseSpec value(String name, Matcher matcher) { String value = getCookie(name).getValue(); @@ -71,7 +73,7 @@ public WebTestClient.ResponseSpec value(String name, Matcher mat } /** - * Consume the value of the response cookie. + * Consume the value of the response cookie with the given name. */ public WebTestClient.ResponseSpec value(String name, Consumer consumer) { String value = getCookie(name).getValue(); @@ -94,25 +96,25 @@ public WebTestClient.ResponseSpec doesNotExist(String name) { ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name); if (cookie != null) { String message = getMessage(name) + " exists with value=[" + cookie.getValue() + "]"; - this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.fail(message)); + this.exchangeResult.assertWithDiagnostics(() -> fail(message)); } return this.responseSpec; } /** - * Assert a cookie's maxAge attribute. + * Assert a cookie's "Max-Age" attribute. */ public WebTestClient.ResponseSpec maxAge(String name, Duration expected) { Duration maxAge = getCookie(name).getMaxAge(); this.exchangeResult.assertWithDiagnostics(() -> { String message = getMessage(name) + " maxAge"; - AssertionErrors.assertEquals(message, expected, maxAge); + assertEquals(message, expected, maxAge); }); return this.responseSpec; } /** - * Assert a cookie's maxAge attribute with a Hamcrest {@link Matcher}. + * Assert a cookie's "Max-Age" attribute with a Hamcrest {@link Matcher}. */ public WebTestClient.ResponseSpec maxAge(String name, Matcher matcher) { long maxAge = getCookie(name).getMaxAge().getSeconds(); @@ -124,19 +126,19 @@ public WebTestClient.ResponseSpec maxAge(String name, Matcher matc } /** - * Assert a cookie's path attribute. + * Assert a cookie's "Path" attribute. */ public WebTestClient.ResponseSpec path(String name, String expected) { String path = getCookie(name).getPath(); this.exchangeResult.assertWithDiagnostics(() -> { String message = getMessage(name) + " path"; - AssertionErrors.assertEquals(message, expected, path); + assertEquals(message, expected, path); }); return this.responseSpec; } /** - * Assert a cookie's path attribute with a Hamcrest {@link Matcher}. + * Assert a cookie's "Path" attribute with a Hamcrest {@link Matcher}. */ public WebTestClient.ResponseSpec path(String name, Matcher matcher) { String path = getCookie(name).getPath(); @@ -148,19 +150,19 @@ public WebTestClient.ResponseSpec path(String name, Matcher matc } /** - * Assert a cookie's domain attribute. + * Assert a cookie's "Domain" attribute. */ public WebTestClient.ResponseSpec domain(String name, String expected) { String path = getCookie(name).getDomain(); this.exchangeResult.assertWithDiagnostics(() -> { String message = getMessage(name) + " domain"; - AssertionErrors.assertEquals(message, expected, path); + assertEquals(message, expected, path); }); return this.responseSpec; } /** - * Assert a cookie's domain attribute with a Hamcrest {@link Matcher}. + * Assert a cookie's "Domain" attribute with a Hamcrest {@link Matcher}. */ public WebTestClient.ResponseSpec domain(String name, Matcher matcher) { String domain = getCookie(name).getDomain(); @@ -172,37 +174,50 @@ public WebTestClient.ResponseSpec domain(String name, Matcher ma } /** - * Assert a cookie's secure attribute. + * Assert a cookie's "Secure" attribute. */ public WebTestClient.ResponseSpec secure(String name, boolean expected) { boolean isSecure = getCookie(name).isSecure(); this.exchangeResult.assertWithDiagnostics(() -> { String message = getMessage(name) + " secure"; - AssertionErrors.assertEquals(message, expected, isSecure); + assertEquals(message, expected, isSecure); }); return this.responseSpec; } /** - * Assert a cookie's httpOnly attribute. + * Assert a cookie's "HttpOnly" attribute. */ public WebTestClient.ResponseSpec httpOnly(String name, boolean expected) { boolean isHttpOnly = getCookie(name).isHttpOnly(); this.exchangeResult.assertWithDiagnostics(() -> { String message = getMessage(name) + " httpOnly"; - AssertionErrors.assertEquals(message, expected, isHttpOnly); + assertEquals(message, expected, isHttpOnly); }); return this.responseSpec; } /** - * Assert a cookie's sameSite attribute. + * Assert a cookie's "Partitioned" attribute. + * @since 6.2 + */ + public WebTestClient.ResponseSpec partitioned(String name, boolean expected) { + boolean isPartitioned = getCookie(name).isPartitioned(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " isPartitioned"; + assertEquals(message, expected, isPartitioned); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "SameSite" attribute. */ public WebTestClient.ResponseSpec sameSite(String name, String expected) { String sameSite = getCookie(name).getSameSite(); this.exchangeResult.assertWithDiagnostics(() -> { String message = getMessage(name) + " sameSite"; - AssertionErrors.assertEquals(message, expected, sameSite); + assertEquals(message, expected, sameSite); }); return this.responseSpec; } @@ -210,14 +225,16 @@ public WebTestClient.ResponseSpec sameSite(String name, String expected) { private ResponseCookie getCookie(String name) { ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name); - if (cookie == null) { - this.exchangeResult.assertWithDiagnostics(() -> - AssertionErrors.fail("No cookie with name '" + name + "'")); + if (cookie != null) { + return cookie; + } + else { + this.exchangeResult.assertWithDiagnostics(() -> fail("No cookie with name '" + name + "'")); } - return Objects.requireNonNull(cookie); + throw new IllegalStateException("This code path should not be reachable"); } - private String getMessage(String cookie) { + private static String getMessage(String cookie) { return "Response cookie '" + cookie + "'"; } 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 a76b9bc972b5..5b8597a68ec5 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 @@ -30,6 +30,8 @@ import java.util.function.Consumer; import java.util.function.Function; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.spi.mapper.MappingProvider; import org.hamcrest.Matcher; import org.hamcrest.MatcherAssert; import org.reactivestreams.Publisher; @@ -43,9 +45,11 @@ import org.springframework.http.client.reactive.ClientHttpConnector; import org.springframework.http.client.reactive.ClientHttpRequest; import org.springframework.lang.Nullable; +import org.springframework.test.json.JsonAssert; +import org.springframework.test.json.JsonComparator; +import org.springframework.test.json.JsonCompareMode; import org.springframework.test.util.AssertionErrors; import org.springframework.test.util.ExceptionCollector; -import org.springframework.test.util.JsonExpectationsHelper; import org.springframework.test.util.XmlExpectationsHelper; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; @@ -57,6 +61,7 @@ import org.springframework.web.reactive.function.client.ClientRequest; import org.springframework.web.reactive.function.client.ClientResponse; import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.util.UriBuilder; import org.springframework.web.util.UriBuilderFactory; @@ -73,6 +78,9 @@ class DefaultWebTestClient implements WebTestClient { private final WiretapConnector wiretapConnector; + @Nullable + private final JsonEncoderDecoder jsonEncoderDecoder; + private final ExchangeFunction exchangeFunction; private final UriBuilderFactory uriBuilderFactory; @@ -92,13 +100,15 @@ class DefaultWebTestClient implements WebTestClient { private final AtomicLong requestIndex = new AtomicLong(); - DefaultWebTestClient(ClientHttpConnector connector, + DefaultWebTestClient(ClientHttpConnector connector, ExchangeStrategies exchangeStrategies, Function exchangeFactory, UriBuilderFactory uriBuilderFactory, @Nullable HttpHeaders headers, @Nullable MultiValueMap cookies, Consumer> entityResultConsumer, @Nullable Duration responseTimeout, DefaultWebTestClientBuilder clientBuilder) { this.wiretapConnector = new WiretapConnector(connector); + this.jsonEncoderDecoder = JsonEncoderDecoder.from( + exchangeStrategies.messageWriters(), exchangeStrategies.messageReaders()); this.exchangeFunction = exchangeFactory.apply(this.wiretapConnector); this.uriBuilderFactory = uriBuilderFactory; this.defaultHeaders = headers; @@ -363,6 +373,7 @@ public ResponseSpec exchange() { this.requestId, this.uriTemplate, getResponseTimeout()); return new DefaultResponseSpec(result, response, + DefaultWebTestClient.this.jsonEncoderDecoder, DefaultWebTestClient.this.entityResultConsumer, getResponseTimeout()); } @@ -400,6 +411,9 @@ private static class DefaultResponseSpec implements ResponseSpec { private final ClientResponse response; + @Nullable + private final JsonEncoderDecoder jsonEncoderDecoder; + private final Consumer> entityResultConsumer; private final Duration timeout; @@ -407,11 +421,13 @@ private static class DefaultResponseSpec implements ResponseSpec { DefaultResponseSpec( ExchangeResult exchangeResult, ClientResponse response, + @Nullable JsonEncoderDecoder jsonEncoderDecoder, Consumer> entityResultConsumer, Duration timeout) { this.exchangeResult = exchangeResult; this.response = response; + this.jsonEncoderDecoder = jsonEncoderDecoder; this.entityResultConsumer = entityResultConsumer; this.timeout = timeout; } @@ -467,7 +483,7 @@ public BodyContentSpec expectBody() { ByteArrayResource resource = this.response.bodyToMono(ByteArrayResource.class).block(this.timeout); byte[] body = (resource != null ? resource.getByteArray() : null); EntityExchangeResult entityResult = initEntityExchangeResult(body); - return new DefaultBodyContentSpec(entityResult); + return new DefaultBodyContentSpec(entityResult, this.jsonEncoderDecoder); } private EntityExchangeResult initEntityExchangeResult(@Nullable B body) { @@ -633,10 +649,14 @@ private static class DefaultBodyContentSpec implements BodyContentSpec { private final EntityExchangeResult result; + @Nullable + private final JsonEncoderDecoder jsonEncoderDecoder; + private final boolean isEmpty; - DefaultBodyContentSpec(EntityExchangeResult result) { + DefaultBodyContentSpec(EntityExchangeResult result, @Nullable JsonEncoderDecoder jsonEncoderDecoder) { this.result = result; + this.jsonEncoderDecoder = jsonEncoderDecoder; this.isEmpty = (result.getResponseBody() == null || result.getResponseBody().length == 0); } @@ -648,10 +668,22 @@ public EntityExchangeResult isEmpty() { } @Override + @Deprecated(since = "6.2") public BodyContentSpec json(String json, boolean strict) { + JsonCompareMode compareMode = (strict ? JsonCompareMode.STRICT : JsonCompareMode.LENIENT); + return json(json, compareMode); + } + + @Override + public BodyContentSpec json(String expectedJson, JsonCompareMode compareMode) { + return json(expectedJson, JsonAssert.comparator(compareMode)); + } + + @Override + public BodyContentSpec json(String expectedJson, JsonComparator comparator) { this.result.assertWithDiagnostics(() -> { try { - new JsonExpectationsHelper().assertJsonEqual(json, getBodyAsString(), strict); + comparator.assertIsMatch(expectedJson, getBodyAsString()); } catch (Exception ex) { throw new AssertionError("JSON parsing error", ex); @@ -674,8 +706,16 @@ public BodyContentSpec xml(String expectedXml) { } @Override + public JsonPathAssertions jsonPath(String expression) { + return new JsonPathAssertions(this, getBodyAsString(), expression, + JsonPathConfigurationProvider.getConfiguration(this.jsonEncoderDecoder)); + } + + @Override + @SuppressWarnings("removal") public JsonPathAssertions jsonPath(String expression, Object... args) { - return new JsonPathAssertions(this, getBodyAsString(), expression, args); + Assert.hasText(expression, "expression must not be null or empty"); + return jsonPath(expression.formatted(args)); } @Override @@ -705,4 +745,18 @@ public EntityExchangeResult returnResult() { } } + + private static class JsonPathConfigurationProvider { + + static Configuration getConfiguration(@Nullable JsonEncoderDecoder jsonEncoderDecoder) { + Configuration jsonPathConfiguration = Configuration.defaultConfiguration(); + if (jsonEncoderDecoder != null) { + MappingProvider mappingProvider = new EncoderDecoderMappingProvider( + jsonEncoderDecoder.encoder(), jsonEncoderDecoder.decoder()); + return jsonPathConfiguration.mappingProvider(mappingProvider); + } + return jsonPathConfiguration; + } + } + } 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 996db49b8b36..61a5e47f5a62 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -294,8 +294,9 @@ public WebTestClient build() { if (connectorToUse == null) { connectorToUse = initConnector(); } + ExchangeStrategies exchangeStrategies = initExchangeStrategies(); Function exchangeFactory = connector -> { - ExchangeFunction exchange = ExchangeFunctions.create(connector, initExchangeStrategies()); + ExchangeFunction exchange = ExchangeFunctions.create(connector, exchangeStrategies); if (CollectionUtils.isEmpty(this.filters)) { return exchange; } @@ -305,7 +306,7 @@ public WebTestClient build() { .orElse(exchange); }; - return new DefaultWebTestClient(connectorToUse, exchangeFactory, initUriBuilderFactory(), + return new DefaultWebTestClient(connectorToUse, exchangeStrategies, exchangeFactory, initUriBuilderFactory(), this.defaultHeaders != null ? HttpHeaders.readOnlyHttpHeaders(this.defaultHeaders) : null, this.defaultCookies != null ? CollectionUtils.unmodifiableMultiValueMap(this.defaultCookies) : null, this.entityResultConsumer, this.responseTimeout, new DefaultWebTestClientBuilder(this)); diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProvider.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProvider.java new file mode 100644 index 000000000000..f6a6dc1cddb3 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProvider.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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; + +import java.util.Collections; +import java.util.Map; + +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.TypeRef; +import com.jayway.jsonpath.spi.mapper.MappingProvider; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.lang.Nullable; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +/** + * JSON Path {@link MappingProvider} implementation using {@link Encoder} + * and {@link Decoder}. + * + * @author Rossen Stoyanchev + * @author Stephane Nicoll + * @since 6.2 + */ +final class EncoderDecoderMappingProvider implements MappingProvider { + + private final Encoder encoder; + + private final Decoder decoder; + + /** + * Create an instance with the specified writers and readers. + */ + public EncoderDecoderMappingProvider(Encoder encoder, Decoder decoder) { + this.encoder = encoder; + this.decoder = decoder; + } + + + @Nullable + @Override + public T map(Object source, Class targetType, Configuration configuration) { + return mapToTargetType(source, ResolvableType.forClass(targetType)); + } + + @Nullable + @Override + public T map(Object source, TypeRef targetType, Configuration configuration) { + return mapToTargetType(source, ResolvableType.forType(targetType.getType())); + } + + @SuppressWarnings("unchecked") + @Nullable + private T mapToTargetType(Object source, ResolvableType targetType) { + DataBufferFactory bufferFactory = DefaultDataBufferFactory.sharedInstance; + MimeType mimeType = MimeTypeUtils.APPLICATION_JSON; + Map hints = Collections.emptyMap(); + + DataBuffer buffer = ((Encoder) this.encoder).encodeValue( + (T) source, bufferFactory, ResolvableType.forInstance(source), mimeType, hints); + + return ((Decoder) this.decoder).decode(buffer, targetType, mimeType, hints); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeResult.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeResult.java index 3b4700a8beae..881b7c0654a4 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeResult.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeResult.java @@ -80,7 +80,7 @@ public class ExchangeResult { @Nullable private final Object mockServerResult; - /** Ensure single logging, e.g. for expectAll. */ + /** Ensure single logging, for example, for expectAll. */ private boolean diagnosticsLogged; diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java index c3750d27c3c4..fe4abf6c857a 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author 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,23 +19,22 @@ import java.net.URI; import java.util.Arrays; import java.util.List; -import java.util.Objects; import java.util.function.Consumer; import org.hamcrest.Matcher; -import org.hamcrest.MatcherAssert; import org.springframework.http.CacheControl; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.lang.Nullable; -import org.springframework.test.util.AssertionErrors; import org.springframework.util.CollectionUtils; +import static org.hamcrest.MatcherAssert.assertThat; import static org.springframework.test.util.AssertionErrors.assertEquals; import static org.springframework.test.util.AssertionErrors.assertNotNull; import static org.springframework.test.util.AssertionErrors.assertTrue; +import static org.springframework.test.util.AssertionErrors.fail; /** * Assertions on headers of the response. @@ -73,8 +72,8 @@ public WebTestClient.ResponseSpec valueEquals(String headerName, String... value public WebTestClient.ResponseSpec valueEquals(String headerName, long value) { String actual = getHeaders().getFirst(headerName); this.exchangeResult.assertWithDiagnostics(() -> - assertTrue("Response does not contain header '" + headerName + "'", actual != null)); - return assertHeader(headerName, value, Long.parseLong(Objects.requireNonNull(actual))); + assertNotNull("Response does not contain header '" + headerName + "'", actual)); + return assertHeader(headerName, value, Long.parseLong(actual)); } /** @@ -94,7 +93,7 @@ public WebTestClient.ResponseSpec valueEqualsDate(String headerName, long value) headers.setDate("expected", value); headers.set("actual", headerValue); - assertEquals("Response header '" + headerName + "'='" + headerValue + "' " + + assertEquals(getMessage(headerName) + "='" + headerValue + "' " + "does not match expected value '" + headers.getFirst("expected") + "'", headers.getFirstDate("expected"), headers.getFirstDate("actual")); }); @@ -109,7 +108,7 @@ public WebTestClient.ResponseSpec valueEqualsDate(String headerName, long value) public WebTestClient.ResponseSpec valueMatches(String name, String pattern) { String value = getRequiredValue(name); String message = getMessage(name) + "=[" + value + "] does not match [" + pattern + "]"; - this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.assertTrue(message, value.matches(pattern))); + this.exchangeResult.assertWithDiagnostics(() -> assertTrue(message, value.matches(pattern))); return this.responseSpec; } @@ -123,16 +122,16 @@ public WebTestClient.ResponseSpec valueMatches(String name, String pattern) { * @since 5.3 */ public WebTestClient.ResponseSpec valuesMatch(String name, String... patterns) { + List values = getRequiredValues(name); this.exchangeResult.assertWithDiagnostics(() -> { - List values = getRequiredValues(name); - AssertionErrors.assertTrue( + assertTrue( getMessage(name) + " has fewer or more values " + values + " than number of patterns to match with " + Arrays.toString(patterns), values.size() == patterns.length); for (int i = 0; i < values.size(); i++) { String value = values.get(i); String pattern = patterns[i]; - AssertionErrors.assertTrue( + assertTrue( getMessage(name) + "[" + i + "]='" + value + "' does not match '" + pattern + "'", value.matches(pattern)); } @@ -150,7 +149,7 @@ public WebTestClient.ResponseSpec value(String name, Matcher mat String value = getHeaders().getFirst(name); this.exchangeResult.assertWithDiagnostics(() -> { String message = getMessage(name); - MatcherAssert.assertThat(message, value, matcher); + assertThat(message, value, matcher); }); return this.responseSpec; } @@ -165,7 +164,7 @@ public WebTestClient.ResponseSpec values(String name, Matcher values = getHeaders().get(name); this.exchangeResult.assertWithDiagnostics(() -> { String message = getMessage(name); - MatcherAssert.assertThat(message, values, matcher); + assertThat(message, values, matcher); }); return this.responseSpec; } @@ -200,11 +199,13 @@ private String getRequiredValue(String name) { private List getRequiredValues(String name) { List values = getHeaders().get(name); - if (CollectionUtils.isEmpty(values)) { - this.exchangeResult.assertWithDiagnostics(() -> - AssertionErrors.fail(getMessage(name) + " not found")); + if (!CollectionUtils.isEmpty(values)) { + return values; + } + else { + this.exchangeResult.assertWithDiagnostics(() -> fail(getMessage(name) + " not found")); } - return Objects.requireNonNull(values); + throw new IllegalStateException("This code path should not be reachable"); } /** @@ -214,7 +215,7 @@ private List getRequiredValues(String name) { public WebTestClient.ResponseSpec exists(String name) { if (!getHeaders().containsKey(name)) { String message = getMessage(name) + " does not exist"; - this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.fail(message)); + this.exchangeResult.assertWithDiagnostics(() -> fail(message)); } return this.responseSpec; } @@ -225,7 +226,7 @@ public WebTestClient.ResponseSpec exists(String name) { public WebTestClient.ResponseSpec doesNotExist(String name) { if (getHeaders().containsKey(name)) { String message = getMessage(name) + " exists with value=[" + getHeaders().getFirst(name) + "]"; - this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.fail(message)); + this.exchangeResult.assertWithDiagnostics(() -> fail(message)); } return this.responseSpec; } @@ -272,7 +273,7 @@ public WebTestClient.ResponseSpec contentTypeCompatibleWith(MediaType mediaType) MediaType actual = getHeaders().getContentType(); String message = getMessage("Content-Type") + "=[" + actual + "] is not compatible with [" + mediaType + "]"; this.exchangeResult.assertWithDiagnostics(() -> - AssertionErrors.assertTrue(message, (actual != null && actual.isCompatibleWith(mediaType)))); + assertTrue(message, (actual != null && actual.isCompatibleWith(mediaType)))); return this.responseSpec; } @@ -310,16 +311,16 @@ private HttpHeaders getHeaders() { return this.exchangeResult.getResponseHeaders(); } - private String getMessage(String headerName) { - return "Response header '" + headerName + "'"; - } - private WebTestClient.ResponseSpec assertHeader(String name, @Nullable Object expected, @Nullable Object actual) { this.exchangeResult.assertWithDiagnostics(() -> { String message = getMessage(name); - AssertionErrors.assertEquals(message, expected, actual); + assertEquals(message, expected, actual); }); return this.responseSpec; } + private static String getMessage(String headerName) { + return "Response header '" + headerName + "'"; + } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HttpHandlerConnector.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HttpHandlerConnector.java index b89b664de23d..f0cfd1ef69c0 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HttpHandlerConnector.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HttpHandlerConnector.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonEncoderDecoder.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonEncoderDecoder.java new file mode 100644 index 000000000000..ae861713ebe4 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonEncoderDecoder.java @@ -0,0 +1,114 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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; + +import java.util.Collection; +import java.util.Map; +import java.util.stream.Stream; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.http.MediaType; +import org.springframework.http.codec.DecoderHttpMessageReader; +import org.springframework.http.codec.EncoderHttpMessageWriter; +import org.springframework.http.codec.HttpMessageReader; +import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.lang.Nullable; + +/** + * {@link Encoder} and {@link Decoder} that is able to encode and decode + * a {@link Map} to and from JSON. + * + *

    Used to configure the jsonpath infrastructure without having a hard + * dependency on the library. + * + * @author Stephane Nicoll + * @author Rossen Stoyanchev + * @since 6.2 + * @param encoder the JSON encoder + * @param decoder the JSON decoder + */ +record JsonEncoderDecoder(Encoder encoder, Decoder decoder) { + + private static final ResolvableType MAP_TYPE = ResolvableType.forClass(Map.class); + + + /** + * Create a {@link JsonEncoderDecoder} instance based on the specified + * infrastructure. + * @param messageWriters the HTTP message writers + * @param messageReaders the HTTP message readers + * @return a {@link JsonEncoderDecoder} or {@code null} if a suitable codec + * is not available + */ + @Nullable + static JsonEncoderDecoder from(Collection> messageWriters, + Collection> messageReaders) { + + Encoder jsonEncoder = findJsonEncoder(messageWriters); + Decoder jsonDecoder = findJsonDecoder(messageReaders); + if (jsonEncoder != null && jsonDecoder != null) { + return new JsonEncoderDecoder(jsonEncoder, jsonDecoder); + } + return null; + } + + + /** + * Find the first suitable {@link Encoder} that can encode a {@link Map} + * to JSON. + * @param writers the writers to inspect + * @return a suitable JSON {@link Encoder} or {@code null} + */ + @Nullable + private static Encoder findJsonEncoder(Collection> writers) { + return findJsonEncoder(writers.stream() + .filter(EncoderHttpMessageWriter.class::isInstance) + .map(writer -> ((EncoderHttpMessageWriter) writer).getEncoder())); + } + + @Nullable + private static Encoder findJsonEncoder(Stream> stream) { + return stream + .filter(encoder -> encoder.canEncode(MAP_TYPE, MediaType.APPLICATION_JSON)) + .findFirst() + .orElse(null); + } + + /** + * Find the first suitable {@link Decoder} that can decode a {@link Map} from + * JSON. + * @param readers the readers to inspect + * @return a suitable JSON {@link Decoder} or {@code null} + */ + @Nullable + private static Decoder findJsonDecoder(Collection> readers) { + return findJsonDecoder(readers.stream() + .filter(DecoderHttpMessageReader.class::isInstance) + .map(reader -> ((DecoderHttpMessageReader) reader).getDecoder())); + } + + @Nullable + private static Decoder findJsonDecoder(Stream> decoderStream) { + return decoderStream + .filter(decoder -> decoder.canDecode(MAP_TYPE, MediaType.APPLICATION_JSON)) + .findFirst() + .orElse(null); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java index c0c2d861fd38..d761575ac839 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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,15 +18,19 @@ import java.util.function.Consumer; +import com.jayway.jsonpath.Configuration; import org.hamcrest.Matcher; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.lang.Nullable; import org.springframework.test.util.JsonPathExpectationsHelper; +import org.springframework.util.Assert; /** * JsonPath assertions. * * @author Rossen Stoyanchev + * @author Stephane Nicoll * @since 5.0 * @see https://github.com/jayway/JsonPath * @see JsonPathExpectationsHelper @@ -40,10 +44,12 @@ public class JsonPathAssertions { private final JsonPathExpectationsHelper pathHelper; - JsonPathAssertions(WebTestClient.BodyContentSpec spec, String content, String expression, Object... args) { + JsonPathAssertions(WebTestClient.BodyContentSpec spec, String content, String expression, + @Nullable Configuration configuration) { + Assert.hasText(expression, "expression must not be null or empty"); this.bodySpec = spec; this.content = content; - this.pathHelper = new JsonPathExpectationsHelper(expression, args); + this.pathHelper = new JsonPathExpectationsHelper(expression, configuration); } @@ -146,15 +152,35 @@ public WebTestClient.BodyContentSpec value(Matcher matcher) { return this.bodySpec; } + /** + * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, Class)}. + * @since 6.2 + */ + public WebTestClient.BodyContentSpec value(Class targetType, Matcher matcher) { + this.pathHelper.assertValue(this.content, matcher, targetType); + return this.bodySpec; + } + /** * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, Class)}. * @since 5.1 + * @deprecated in favor of {@link #value(Class, Matcher)} */ + @Deprecated(since = "6.2", forRemoval = true) public WebTestClient.BodyContentSpec value(Matcher matcher, Class targetType) { this.pathHelper.assertValue(this.content, matcher, targetType); return this.bodySpec; } + /** + * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, ParameterizedTypeReference)}. + * @since 6.2 + */ + public WebTestClient.BodyContentSpec value(ParameterizedTypeReference targetType, Matcher matcher) { + this.pathHelper.assertValue(this.content, matcher, targetType); + return this.bodySpec; + } + /** * Consume the result of the JSONPath evaluation. * @since 5.1 @@ -166,17 +192,35 @@ public WebTestClient.BodyContentSpec value(Consumer consumer) { return this.bodySpec; } + /** + * Consume the result of the JSONPath evaluation and provide a target class. + * @since 6.2 + */ + public WebTestClient.BodyContentSpec value(Class targetType, Consumer consumer) { + T value = this.pathHelper.evaluateJsonPath(this.content, targetType); + consumer.accept(value); + return this.bodySpec; + } + /** * Consume the result of the JSONPath evaluation and provide a target class. * @since 5.1 + * @deprecated in favor of {@link #value(Class, Consumer)} */ - @SuppressWarnings("unchecked") + @Deprecated(since = "6.2", forRemoval = true) public WebTestClient.BodyContentSpec value(Consumer consumer, Class targetType) { - Object value = this.pathHelper.evaluateJsonPath(this.content, targetType); - consumer.accept((T) value); - return this.bodySpec; + return value(targetType, consumer); } + /** + * Consume the result of the JSONPath evaluation and provide a parameterized type. + * @since 6.2 + */ + public WebTestClient.BodyContentSpec value(ParameterizedTypeReference targetType, Consumer consumer) { + T value = this.pathHelper.evaluateJsonPath(this.content, targetType); + consumer.accept(value); + return this.bodySpec; + } @Override public boolean equals(@Nullable Object obj) { diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/MockServerConfigurer.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/MockServerConfigurer.java index ec6a6e116394..011daf484350 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/MockServerConfigurer.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/MockServerConfigurer.java @@ -25,7 +25,7 @@ * *

    An implementation of this interface can be plugged in via * {@link WebTestClient.MockServerSpec#apply} where instances are likely obtained - * via static methods, e.g.: + * via static methods, for example: * *

      * import static org.example.ExampleSetup.securitySetup;
    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 6b4e220728f5..49999e352235 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
    @@ -40,6 +40,9 @@
     import org.springframework.http.codec.ClientCodecConfigurer;
     import org.springframework.http.codec.ServerCodecConfigurer;
     import org.springframework.lang.Nullable;
    +import org.springframework.test.json.JsonComparator;
    +import org.springframework.test.json.JsonCompareMode;
    +import org.springframework.test.json.JsonComparison;
     import org.springframework.util.MultiValueMap;
     import org.springframework.validation.Validator;
     import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder;
    @@ -91,7 +94,7 @@ public interface WebTestClient {
     	/**
     	 * The name of a request header used to assign a unique id to every request
     	 * performed through the {@code WebTestClient}. This can be useful for
    -	 * storing contextual information at all phases of request processing (e.g.
    +	 * storing contextual information at all phases of request processing (for example,
     	 * from a server-side component) under that id and later to look up
     	 * that information once an {@link ExchangeResult} is available.
     	 */
    @@ -550,7 +553,7 @@ interface UriSpec> {
     
     		/**
     		 * Specify the URI for the request using a URI template and URI variables.
    -		 * 

    If a {@link UriBuilderFactory} was configured for the client (e.g. + *

    If a {@link UriBuilderFactory} was configured for the client (for example, * with a base URI) it will be used to expand the URI template. * @return spec to add headers or perform the exchange */ @@ -558,7 +561,7 @@ interface UriSpec> { /** * Specify the URI for the request using a URI template and URI variables. - *

    If a {@link UriBuilderFactory} was configured for the client (e.g. + *

    If a {@link UriBuilderFactory} was configured for the client (for example, * with a base URI) it will be used to expand the URI template. * @return spec to add headers or perform the exchange */ @@ -872,13 +875,13 @@ interface ResponseSpec { /** * Consume and decode the response body to {@code byte[]} and then apply - * assertions on the raw content (e.g. isEmpty, JSONPath, etc.) + * assertions on the raw content (for example, isEmpty, JSONPath, etc.). */ BodyContentSpec expectBody(); /** * Exit the chained flow in order to consume the response body - * externally, e.g. via {@link reactor.test.StepVerifier}. + * externally, for example, via {@link reactor.test.StepVerifier}. *

    Note that when {@code Void.class} is passed in, the response body * is consumed and released. If no content is expected, then consider * using {@code .expectBody().isEmpty()} instead which asserts that @@ -924,7 +927,7 @@ interface BodySpec> { T value(Matcher matcher); /** - * Transform the extracted the body with a function, e.g. extracting a + * Transform the extracted the body with a function, for example, extracting a * property, and assert the mapped value with a {@link Matcher}. * @since 5.1 */ @@ -997,10 +1000,10 @@ interface BodyContentSpec { * JSONassert library * to be on the classpath. * @param expectedJson the expected JSON content - * @see #json(String, boolean) + * @see #json(String, JsonCompareMode) */ default BodyContentSpec json(String expectedJson) { - return json(expectedJson, false); + return json(expectedJson, JsonCompareMode.LENIENT); } /** @@ -1019,9 +1022,37 @@ default BodyContentSpec json(String expectedJson) { * @param strict enables strict checking if {@code true} * @since 5.3.16 * @see #json(String) + * @deprecated in favor of {@link #json(String, JsonCompareMode)} */ + @Deprecated(since = "6.2") BodyContentSpec json(String expectedJson, boolean strict); + /** + * Parse the expected and actual response content as JSON and perform a + * comparison using the given {@linkplain JsonCompareMode mode}. If the + * comparison failed, throws an {@link AssertionError} with the message + * of the {@link JsonComparison}. + *

    Use of this method requires the + * JSONassert library + * to be on the classpath. + * @param expectedJson the expected JSON content + * @param compareMode the compare mode + * @since 6.2 + * @see #json(String) + */ + BodyContentSpec json(String expectedJson, JsonCompareMode compareMode); + + /** + * Parse the expected and actual response content as JSON and perform a + * comparison using the given {@link JsonComparator}. If the comparison + * failed, throws an {@link AssertionError} with the message of the + * {@link JsonComparison}. + * @param expectedJson the expected JSON content + * @param comparator the comparator to use + * @since 6.2 + */ + BodyContentSpec json(String expectedJson, JsonComparator comparator); + /** * Parse expected and actual response content as XML and assert that * the two are "similar", i.e. they contain the same elements and @@ -1029,12 +1060,21 @@ default BodyContentSpec json(String expectedJson) { *

    Use of this method requires the * XMLUnit library on * the classpath. - * @param expectedXml the expected JSON content. + * @param expectedXml the expected XML content. * @since 5.1 * @see org.springframework.test.util.XmlExpectationsHelper#assertXmlEqual(String, String) */ BodyContentSpec xml(String expectedXml); + /** + * Access to response body assertions using a + * JsonPath expression + * to inspect a specific subset of the body. + * @param expression the JsonPath expression + * @since 6.2 + */ + JsonPathAssertions jsonPath(String expression); + /** * Access to response body assertions using a * JsonPath expression @@ -1043,7 +1083,9 @@ default BodyContentSpec json(String expectedJson) { * formatting specifiers as defined in {@link String#format}. * @param expression the JsonPath expression * @param args arguments to parameterize the expression + * @deprecated in favor of calling {@link String#formatted(Object...)} upfront */ + @Deprecated(since = "6.2", forRemoval = true) JsonPathAssertions jsonPath(String expression, Object... args); /** diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WiretapConnector.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WiretapConnector.java index 1c5d91caeabd..c502d883ade6 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WiretapConnector.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WiretapConnector.java @@ -62,6 +62,7 @@ class WiretapConnector implements ClientHttpConnector { @Override + @SuppressWarnings("NullAway") public Mono connect(HttpMethod method, URI uri, Function> requestCallback) { @@ -181,6 +182,7 @@ public Publisher> getNestedPublisherTo return this.publisherNested; } + @SuppressWarnings("NullAway") public Mono getContent() { return Mono.defer(() -> { if (this.content.scan(Scannable.Attr.TERMINATED) == Boolean.TRUE) { @@ -188,7 +190,7 @@ public Mono getContent() { } if (!this.hasContentConsumer) { // Couple of possible cases: - // 1. Mock server never consumed request body (e.g. error before read) + // 1. Mock server never consumed request body (for example, error before read) // 2. FluxExchangeResult: getResponseBodyContent called before getResponseBody //noinspection ConstantConditions (this.publisher != null ? this.publisher : this.publisherNested) diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/DefaultMvcResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/DefaultMvcResult.java index 02db2d58fbfe..5582235cfb38 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/DefaultMvcResult.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/DefaultMvcResult.java @@ -137,6 +137,7 @@ public Object getAsyncResult() { } @Override + @SuppressWarnings("NullAway") public Object getAsyncResult(long timeToWait) { if (this.mockRequest.getAsyncContext() != null && timeToWait == -1) { long requestTimeout = this.mockRequest.getAsyncContext().getTimeout(); diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssert.java new file mode 100644 index 000000000000..396e0cb21e1d --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssert.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.function.Function; +import java.util.function.Supplier; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.MapAssert; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.util.function.SingletonSupplier; +import org.springframework.web.context.request.async.DeferredResult; + +/** + * Base AssertJ {@linkplain org.assertj.core.api.Assert assertions} that can be + * applied to an {@link HttpServletRequest}. + * + * @author Stephane Nicoll + * @since 6.2 + * @param the type of assertions + * @param the type of the object to assert + */ +public abstract class AbstractHttpServletRequestAssert, ACTUAL extends HttpServletRequest> + extends AbstractObjectAssert { + + private final Supplier> attributesAssertProvider; + + private final Supplier> sessionAttributesAssertProvider; + + protected AbstractHttpServletRequestAssert(ACTUAL actual, Class selfType) { + super(actual, selfType); + this.attributesAssertProvider = SingletonSupplier.of(() -> createAttributesAssert(actual)); + this.sessionAttributesAssertProvider = SingletonSupplier.of(() -> createSessionAttributesAssert(actual)); + } + + private static MapAssert createAttributesAssert(HttpServletRequest request) { + Map map = toMap(request.getAttributeNames(), request::getAttribute); + return Assertions.assertThat(map).as("Request Attributes"); + } + + private static MapAssert createSessionAttributesAssert(HttpServletRequest request) { + HttpSession session = request.getSession(); + Assertions.assertThat(session).as("HTTP session").isNotNull(); + Map map = toMap(session.getAttributeNames(), session::getAttribute); + return Assertions.assertThat(map).as("Session Attributes"); + } + + /** + * Return a new {@linkplain MapAssert assertion} object that uses the request + * attributes as the object to test, with values mapped by attribute name. + *

    Example:

    
    +	 * // Check for the presence of a request attribute named "attributeName":
    +	 * assertThat(request).attributes().containsKey("attributeName");
    +	 * 
    + */ + public MapAssert attributes() { + return this.attributesAssertProvider.get(); + } + + /** + * Return a new {@linkplain MapAssert assertion} object that uses the session + * attributes as the object to test, with values mapped by attribute name. + *

    Example:

    
    +	 * // Check for the presence of a session attribute named "username":
    +	 * assertThat(request).sessionAttributes().containsKey("username");
    +	 * 
    + */ + public MapAssert sessionAttributes() { + return this.sessionAttributesAssertProvider.get(); + } + + /** + * Verify whether asynchronous processing has started, usually as a result + * of a controller method returning a {@link Callable} or {@link DeferredResult}. + *

    The test will await the completion of a {@code Callable} so that + * the asynchronous result is available and can be further asserted. + *

    Neither a {@code Callable} nor a {@code DeferredResult} will complete + * processing all the way since a {@link MockHttpServletRequest} does not + * perform asynchronous dispatches. + * @param started whether asynchronous processing should have started + */ + public SELF hasAsyncStarted(boolean started) { + Assertions.assertThat(this.actual.isAsyncStarted()) + .withFailMessage("Async expected %s have started", (started ? "to" : "not to")) + .isEqualTo(started); + return this.myself; + } + + + private static Map toMap(Enumeration keys, Function valueProvider) { + Map map = new LinkedHashMap<>(); + while (keys.hasMoreElements()) { + String key = keys.nextElement(); + map.put(key, valueProvider.apply(key)); + } + return map; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssert.java new file mode 100644 index 000000000000..9b7f42946e5f --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssert.java @@ -0,0 +1,257 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.util.ArrayList; +import java.util.function.Supplier; + +import jakarta.servlet.http.HttpServletResponse; +import org.assertj.core.api.AbstractIntegerAssert; +import org.assertj.core.api.AbstractMapAssert; +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.Assertions; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatus.Series; +import org.springframework.http.MediaType; +import org.springframework.test.http.HttpHeadersAssert; +import org.springframework.test.http.MediaTypeAssert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.function.SingletonSupplier; + +/** + * Base AssertJ {@linkplain org.assertj.core.api.Assert assertions} that can be + * applied to any object that provides an {@link HttpServletResponse}. This + * provides direct access to response assertions while also providing access to + * a different top-level object. + * + * @author Stephane Nicoll + * @since 6.2 + * @param the type of {@link HttpServletResponse} + * @param the type of assertions + * @param the type of the object to assert + */ +public abstract class AbstractHttpServletResponseAssert, ACTUAL> + extends AbstractObjectAssert { + + private final Supplier contentTypeAssertSupplier; + + private final Supplier headersAssertSupplier; + + private final Supplier> statusAssert; + + + protected AbstractHttpServletResponseAssert(ACTUAL actual, Class selfType) { + super(actual, selfType); + this.contentTypeAssertSupplier = SingletonSupplier.of(() -> new MediaTypeAssert(getResponse().getContentType())); + this.headersAssertSupplier = SingletonSupplier.of(() -> new HttpHeadersAssert(getHttpHeaders(getResponse()))); + this.statusAssert = SingletonSupplier.of(() -> Assertions.assertThat(getResponse().getStatus()).as("HTTP status code")); + } + + private static HttpHeaders getHttpHeaders(HttpServletResponse response) { + MultiValueMap headers = new LinkedMultiValueMap<>(); + response.getHeaderNames().forEach(name -> headers.put(name, new ArrayList<>(response.getHeaders(name)))); + return new HttpHeaders(headers); + } + + /** + * Provide the response to use if it is available. + *

    Throws an {@link AssertionError} if the request has failed to process, + * and the response is not available. + * @return the response to use + */ + protected abstract R getResponse(); + + /** + * Return a new {@linkplain MediaTypeAssert assertion} object that uses the + * response's {@linkplain MediaType content type} as the object to test. + */ + public MediaTypeAssert contentType() { + return this.contentTypeAssertSupplier.get(); + } + + /** + * Return a new {@linkplain HttpHeadersAssert assertion} object that uses + * {@link HttpHeaders} as the object to test. The returned assertion + * object provides all the regular {@linkplain AbstractMapAssert map + * assertions}, with headers mapped by header name. + * Examples:

    
    +	 * // Check for the presence of the Accept header:
    +	 * assertThat(response).headers().containsHeader(HttpHeaders.ACCEPT);
    +	 *
    +	 * // Check for the absence of the Content-Length header:
    +	 * assertThat(response).headers().doesNotContainsHeader(HttpHeaders.CONTENT_LENGTH);
    +	 * 
    + */ + public HttpHeadersAssert headers() { + return this.headersAssertSupplier.get(); + } + + // Content-type shortcuts + + /** + * Verify that the response's {@code Content-Type} is equal to the given value. + * @param contentType the expected content type + */ + public SELF hasContentType(MediaType contentType) { + contentType().isEqualTo(contentType); + return this.myself; + } + + /** + * Verify that the response's {@code Content-Type} is equal to the given + * string representation. + * @param contentType the expected content type + */ + public SELF hasContentType(String contentType) { + contentType().isEqualTo(contentType); + return this.myself; + } + + /** + * Verify that the response's {@code Content-Type} is + * {@linkplain MediaType#isCompatibleWith(MediaType) compatible} with the + * given value. + * @param contentType the expected compatible content type + */ + public SELF hasContentTypeCompatibleWith(MediaType contentType) { + contentType().isCompatibleWith(contentType); + return this.myself; + } + + /** + * Verify that the response's {@code Content-Type} is + * {@linkplain MediaType#isCompatibleWith(MediaType) compatible} with the + * given string representation. + * @param contentType the expected compatible content type + */ + public SELF hasContentTypeCompatibleWith(String contentType) { + contentType().isCompatibleWith(contentType); + return this.myself; + } + + // Headers shortcuts + + /** + * Verify that the response contains a header with the given {@code name}. + * @param name the name of an expected HTTP header + */ + public SELF containsHeader(String name) { + headers().containsHeader(name); + return this.myself; + } + + /** + * Verify that the response does not contain a header with the given {@code name}. + * @param name the name of an HTTP header that should not be present + */ + public SELF doesNotContainHeader(String name) { + headers().doesNotContainHeader(name); + return this.myself; + } + + /** + * Verify that the response contains a header with the given {@code name} + * and primary {@code value}. + * @param name the name of an expected HTTP header + * @param value the expected value of the header + */ + public SELF hasHeader(String name, String value) { + headers().hasValue(name, value); + return this.myself; + } + + // Status + + /** + * Verify that the HTTP status is equal to the specified status code. + * @param status the expected HTTP status code + */ + public SELF hasStatus(int status) { + status().isEqualTo(status); + return this.myself; + } + + /** + * Verify that the HTTP status is equal to the specified + * {@linkplain HttpStatus status}. + * @param status the expected HTTP status code + */ + public SELF hasStatus(HttpStatus status) { + return hasStatus(status.value()); + } + + /** + * Verify that the HTTP status is equal to {@link HttpStatus#OK}. + * @see #hasStatus(HttpStatus) + */ + public SELF hasStatusOk() { + return hasStatus(HttpStatus.OK); + } + + /** + * Verify that the HTTP status code is in the 1xx range. + * @see RFC 2616 + */ + public SELF hasStatus1xxInformational() { + return hasStatusSeries(Series.INFORMATIONAL); + } + + /** + * Verify that the HTTP status code is in the 2xx range. + * @see RFC 2616 + */ + public SELF hasStatus2xxSuccessful() { + return hasStatusSeries(Series.SUCCESSFUL); + } + + /** + * Verify that the HTTP status code is in the 3xx range. + * @see RFC 2616 + */ + public SELF hasStatus3xxRedirection() { + return hasStatusSeries(Series.REDIRECTION); + } + + /** + * Verify that the HTTP status code is in the 4xx range. + * @see RFC 2616 + */ + public SELF hasStatus4xxClientError() { + return hasStatusSeries(Series.CLIENT_ERROR); + } + + /** + * Verify that the HTTP status code is in the 5xx range. + * @see RFC 2616 + */ + public SELF hasStatus5xxServerError() { + return hasStatusSeries(Series.SERVER_ERROR); + } + + private SELF hasStatusSeries(Series series) { + Assertions.assertThat(Series.resolve(getResponse().getStatus())).as("HTTP status series").isEqualTo(series); + return this.myself; + } + + private AbstractIntegerAssert status() { + return this.statusAssert.get(); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssert.java new file mode 100644 index 000000000000..b8c98ad7d53a --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssert.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * AssertJ {@linkplain org.assertj.core.api.Assert assertions} that can be applied + * to {@link MockHttpServletRequest}. + * + * @author Stephane Nicoll + * @since 6.2 + * @param the type of assertions + */ +public abstract class AbstractMockHttpServletRequestAssert> + extends AbstractHttpServletRequestAssert { + + protected AbstractMockHttpServletRequestAssert(MockHttpServletRequest request, Class selfType) { + super(request, selfType); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssert.java new file mode 100644 index 000000000000..6fc0dbd79aff --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssert.java @@ -0,0 +1,179 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.nio.charset.Charset; + +import org.assertj.core.api.AbstractByteArrayAssert; +import org.assertj.core.api.AbstractStringAssert; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.ByteArrayAssert; +import org.assertj.core.api.StringAssert; + +import org.springframework.lang.Nullable; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.http.HttpMessageContentConverter; +import org.springframework.test.json.AbstractJsonContentAssert; +import org.springframework.test.json.JsonContent; +import org.springframework.test.json.JsonContentAssert; +import org.springframework.test.web.UriAssert; + +/** + * Extension of {@link AbstractHttpServletResponseAssert} for + * {@link MockHttpServletResponse}. + * + * @author Stephane Nicoll + * @since 6.2 + * @param the type of assertions + * @param the type of the object to assert + */ +public abstract class AbstractMockHttpServletResponseAssert, ACTUAL> + extends AbstractHttpServletResponseAssert { + + @Nullable + private final HttpMessageContentConverter contentConverter; + + protected AbstractMockHttpServletResponseAssert( + @Nullable HttpMessageContentConverter contentConverter, ACTUAL actual, Class selfType) { + + super(actual, selfType); + this.contentConverter = contentConverter; + } + + + /** + * Return a new {@linkplain AbstractStringAssert assertion} object that uses + * the response body converted to text as the object to test. + *

    Examples:

    
    +	 * // Check that the response body is equal to "Hello World":
    +	 * assertThat(response).bodyText().isEqualTo("Hello World");
    +	 * 
    + */ + public AbstractStringAssert bodyText() { + return Assertions.assertThat(readBody()); + } + + /** + * Return a new {@linkplain AbstractJsonContentAssert assertion} object that + * uses the response body converted to text as the object to test. Compared + * to {@link #bodyText()}, the assertion object provides dedicated JSON + * support. + *

    Examples:

    
    +	 * // Check that the response body is strictly equal to the content of
    +	 * // "/com/acme/sample/person-created.json":
    +	 * assertThat(response).bodyJson()
    +	 *         .isStrictlyEqualToJson("/com/acme/sample/person-created.json");
    +	 *
    +	 * // Check that the response is strictly equal to the content of the
    +	 * // specified file located in the same package as the PersonController:
    +	 * assertThat(response).bodyJson().withResourceLoadClass(PersonController.class)
    +	 *         .isStrictlyEqualToJson("person-created.json");
    +	 * 
    + * The returned assert object also supports JSON path expressions. + *

    Examples:

    
    +	 * // Check that the JSON document does not have an "error" element
    +	 * assertThat(response).bodyJson().doesNotHavePath("$.error");
    +	 *
    +	 * // Check that the JSON document as a top level "message" element
    +	 * assertThat(response).bodyJson()
    +	 *         .extractingPath("$.message").asString().isEqualTo("hello");
    +	 * 
    + */ + public AbstractJsonContentAssert bodyJson() { + return new JsonContentAssert(new JsonContent(readBody(), this.contentConverter)); + } + + private String readBody() { + return new String(getResponse().getContentAsByteArray(), + Charset.forName(getResponse().getCharacterEncoding())); + } + + /** + * Return a new {@linkplain AbstractByteArrayAssert assertion} object that + * uses the response body as the object to test. + * @see #bodyText() + * @see #bodyJson() + */ + public AbstractByteArrayAssert body() { + return new ByteArrayAssert(getResponse().getContentAsByteArray()); + } + + /** + * Return a new {@linkplain UriAssert assertion} object that uses the + * forwarded URL as the object to test. If a simple equality check is + * required, consider using {@link #hasForwardedUrl(String)} instead. + *

    Example:

    
    +	 * // Check that the forwarded URL starts with "/orders/":
    +	 * assertThat(response).forwardedUrl().matchPattern("/orders/*);
    +	 * 
    + */ + public UriAssert forwardedUrl() { + return new UriAssert(getResponse().getForwardedUrl(), "Forwarded URL"); + } + + /** + * Return a new {@linkplain UriAssert assertion} object that uses the + * redirected URL as the object to test. If a simple equality check is + * required, consider using {@link #hasRedirectedUrl(String)} instead. + *

    Example:

    
    +	 * // Check that the redirected URL starts with "/orders/":
    +	 * assertThat(response).redirectedUrl().matchPattern("/orders/*);
    +	 * 
    + */ + public UriAssert redirectedUrl() { + return new UriAssert(getResponse().getRedirectedUrl(), "Redirected URL"); + } + + /** + * Verify that the response body is equal to the given value. + */ + public SELF hasBodyTextEqualTo(String bodyText) { + bodyText().isEqualTo(bodyText); + return this.myself; + } + + /** + * Verify that the forwarded URL is equal to the given value. + * @param forwardedUrl the expected forwarded URL (can be null) + */ + public SELF hasForwardedUrl(@Nullable String forwardedUrl) { + forwardedUrl().isEqualTo(forwardedUrl); + return this.myself; + } + + /** + * Verify that the redirected URL is equal to the given value. + * @param redirectedUrl the expected redirected URL (can be null) + */ + public SELF hasRedirectedUrl(@Nullable String redirectedUrl) { + redirectedUrl().isEqualTo(redirectedUrl); + return this.myself; + } + + /** + * Verify that the {@link jakarta.servlet.http.HttpServletResponse#sendError(int, String)} Servlet error message} + * is equal to the given value. + * @param errorMessage the expected Servlet error message (can be null) + * @since 6.2.1 + */ + public SELF hasErrorMessage(@Nullable String errorMessage) { + new StringAssert(getResponse().getErrorMessage()) + .as("Servlet error message").isEqualTo(errorMessage); + return this.myself; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/CookieMapAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/CookieMapAssert.java new file mode 100644 index 000000000000..fd287fadc746 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/CookieMapAssert.java @@ -0,0 +1,166 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +import jakarta.servlet.http.Cookie; +import org.assertj.core.api.AbstractMapAssert; +import org.assertj.core.api.Assertions; + +/** + * AssertJ {@linkplain org.assertj.core.api.Assert assertions} that can be applied + * to {@link Cookie cookies}. + * + * @author Brian Clozel + * @author Stephane Nicoll + * @since 6.2 + */ +public class CookieMapAssert extends AbstractMapAssert, String, Cookie> { + + public CookieMapAssert(Cookie[] actual) { + super(mapCookies(actual), CookieMapAssert.class); + as("Cookies"); + } + + private static Map mapCookies(Cookie[] cookies) { + Map map = new LinkedHashMap<>(); + for (Cookie cookie : cookies) { + map.putIfAbsent(cookie.getName(), cookie); + } + return map; + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name}. + * @param name the name of an expected cookie + * @see #containsKey + */ + public CookieMapAssert containsCookie(String name) { + return containsKey(name); + } + + /** + * Verify that the actual cookies contain cookies with the given {@code names}. + * @param names the names of expected cookies + * @see #containsKeys + */ + public CookieMapAssert containsCookies(String... names) { + return containsKeys(names); + } + + /** + * Verify that the actual cookies do not contain a cookie with the given + * {@code name}. + * @param name the name of a cookie that should not be present + * @see #doesNotContainKey + */ + public CookieMapAssert doesNotContainCookie(String name) { + return doesNotContainKey(name); + } + + /** + * Verify that the actual cookies do not contain any cookies with the given + * {@code names}. + * @param names the names of cookies that should not be present + * @see #doesNotContainKeys + */ + public CookieMapAssert doesNotContainCookies(String... names) { + return doesNotContainKeys(names); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * that satisfies the given {@code cookieRequirements}. + * @param name the name of an expected cookie + * @param cookieRequirements the requirements for the cookie + */ + public CookieMapAssert hasCookieSatisfying(String name, Consumer cookieRequirements) { + return hasEntrySatisfying(name, cookieRequirements); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * whose {@linkplain Cookie#getValue() value} is equal to the given one. + * @param name the name of the cookie + * @param expected the expected value of the cookie + */ + public CookieMapAssert hasValue(String name, String expected) { + return hasCookieSatisfying(name, cookie -> + Assertions.assertThat(cookie.getValue()).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * whose {@linkplain Cookie#getMaxAge() max age} is equal to the given one. + * @param name the name of the cookie + * @param expected the expected max age of the cookie + */ + public CookieMapAssert hasMaxAge(String name, Duration expected) { + return hasCookieSatisfying(name, cookie -> + Assertions.assertThat(Duration.ofSeconds(cookie.getMaxAge())).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * whose {@linkplain Cookie#getPath() path} is equal to the given one. + * @param name the name of the cookie + * @param expected the expected path of the cookie + */ + public CookieMapAssert hasPath(String name, String expected) { + return hasCookieSatisfying(name, cookie -> + Assertions.assertThat(cookie.getPath()).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * whose {@linkplain Cookie#getDomain() domain} is equal to the given one. + * @param name the name of the cookie + * @param expected the expected domain of the cookie + */ + public CookieMapAssert hasDomain(String name, String expected) { + return hasCookieSatisfying(name, cookie -> + Assertions.assertThat(cookie.getDomain()).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * whose {@linkplain Cookie#getSecure() secure flag} is equal to the give one. + * @param name the name of the cookie + * @param expected whether the cookie is secure + */ + public CookieMapAssert isSecure(String name, boolean expected) { + return hasCookieSatisfying(name, cookie -> + Assertions.assertThat(cookie.getSecure()).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * whose {@linkplain Cookie#isHttpOnly() http only flag} is equal to the given + * one. + * @param name the name of the cookie + * @param expected whether the cookie is http only + */ + public CookieMapAssert isHttpOnly(String name, boolean expected) { + return hasCookieSatisfying(name, cookie -> + Assertions.assertThat(cookie.isHttpOnly()).isEqualTo(expected)); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/DefaultMvcTestResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/DefaultMvcTestResult.java new file mode 100644 index 000000000000..5a0303def556 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/DefaultMvcTestResult.java @@ -0,0 +1,80 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import org.springframework.lang.Nullable; +import org.springframework.test.http.HttpMessageContentConverter; +import org.springframework.test.web.servlet.MvcResult; + +/** + * The default {@link MvcTestResult} implementation. + * + * @author Stephane Nicoll + * @since 6.2 + */ +final class DefaultMvcTestResult implements MvcTestResult { + + @Nullable + private final MvcResult mvcResult; + + @Nullable + private final Exception unresolvedException; + + @Nullable + private final HttpMessageContentConverter contentConverter; + + + DefaultMvcTestResult(@Nullable MvcResult mvcResult, @Nullable Exception unresolvedException, + @Nullable HttpMessageContentConverter contentConverter) { + + this.mvcResult = mvcResult; + this.unresolvedException = unresolvedException; + this.contentConverter = contentConverter; + } + + + @Override + public MvcResult getMvcResult() { + if (this.mvcResult == null) { + throw new IllegalStateException( + "Request failed with unresolved exception " + this.unresolvedException); + } + return this.mvcResult; + } + + @Override + @Nullable + public Exception getUnresolvedException() { + return this.unresolvedException; + } + + @Nullable + public Exception getResolvedException() { + return getMvcResult().getResolvedException(); + } + + + /** + * Use AssertJ's {@link org.assertj.core.api.Assertions#assertThat assertThat} + * instead. + */ + @Override + public MvcTestResultAssert assertThat() { + return new MvcTestResultAssert(this, this.contentConverter); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/HandlerResultAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/HandlerResultAssert.java new file mode 100644 index 000000000000..e97be143bdc7 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/HandlerResultAssert.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.lang.reflect.Method; + +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.Assertions; + +import org.springframework.cglib.core.internal.Function; +import org.springframework.lang.Nullable; +import org.springframework.test.util.MethodAssert; +import org.springframework.util.ClassUtils; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder.MethodInvocationInfo; + +/** + * AssertJ {@linkplain org.assertj.core.api.Assert assertions} that can be applied + * to a handler or handler method. + + * @author Stephane Nicoll + * @since 6.2 + */ +public class HandlerResultAssert extends AbstractObjectAssert { + + public HandlerResultAssert(@Nullable Object actual) { + super(actual, HandlerResultAssert.class); + as("Handler result"); + } + + /** + * Return a new {@linkplain MethodAssert assertion} object that uses + * the {@link Method} that handles the request as the object to test. + *

    Verifies first that the handler is a {@linkplain #isMethodHandler() + * method handler}. + *

    Example:

    
    +	 * // Check that a GET to "/greet" is invoked on a "handleGreet" method name
    +	 * assertThat(mvc.perform(get("/greet")).handler().method().hasName("handleGreet");
    +	 * 
    + */ + public MethodAssert method() { + return new MethodAssert(getHandlerMethod()); + } + + /** + * Verify that the handler is managed by a method invocation, typically on + * a controller. + */ + public HandlerResultAssert isMethodHandler() { + return isNotNull().isInstanceOf(HandlerMethod.class); + } + + /** + * Verify that the handler is managed by the given {@code handlerMethod}. + *

    This creates a "mock" for the given {@code controllerType} and records + * the method invocation in the {@code handlerMethod}. The arguments used by + * the target method invocation can be {@code null} as the purpose of the mock + * is to identify the method that was invoked. + *

    Example:

    
    +	 * // If the method has a return type, you can return the result of the invocation
    +	 * assertThat(mvc.perform(get("/greet")).handler().isInvokedOn(
    +	 *         GreetController.class, controller -> controller.sayGreet());
    +	 *
    +	 * // If the method has a void return type, the controller should be returned
    +	 * assertThat(mvc.perform(post("/persons/")).handler().isInvokedOn(
    +	 *         PersonController.class, controller -> controller.createPerson(null, null));
    +	 * 
    + * @param controllerType the controller to mock + * @param handlerMethod the method + */ + public HandlerResultAssert isInvokedOn(Class controllerType, Function handlerMethod) { + MethodAssert actual = method(); + Object methodInvocationInfo = handlerMethod.apply(MvcUriComponentsBuilder.on(controllerType)); + Assertions.assertThat(methodInvocationInfo) + .as("Method invocation on controller '%s'", controllerType.getSimpleName()) + .isInstanceOfSatisfying(MethodInvocationInfo.class, mii -> + actual.isEqualTo(mii.getControllerMethod())); + return this; + } + + /** + * Verify that the handler is of the given {@code type}. For a controller + * method, this is the type of the controller. + *

    Example:

    
    +	 * // Check that a GET to "/greet" is managed by GreetController
    +	 * assertThat(mvc.perform(get("/greet")).handler().hasType(GreetController.class);
    +	 * 
    + * @param type the expected type of the handler + */ + public HandlerResultAssert hasType(Class type) { + isNotNull(); + Class actualType = this.actual.getClass(); + if (this.actual instanceof HandlerMethod handlerMethod) { + actualType = handlerMethod.getBeanType(); + } + Assertions.assertThat(ClassUtils.getUserClass(actualType)).as("Handler result type").isEqualTo(type); + return this; + } + + private Method getHandlerMethod() { + isMethodHandler(); // validate type + return ((HandlerMethod) this.actual).getMethod(); + } + + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MockMvcTester.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MockMvcTester.java new file mode 100644 index 000000000000..309ee6c78a77 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MockMvcTester.java @@ -0,0 +1,549 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.net.URI; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collection; +import java.util.function.Function; + +import jakarta.servlet.DispatcherType; +import org.assertj.core.api.AssertProvider; + +import org.springframework.http.HttpMethod; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockMultipartHttpServletRequest; +import org.springframework.test.http.HttpMessageContentConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.RequestBuilder; +import org.springframework.test.web.servlet.request.AbstractMockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.AbstractMockMultipartHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; +import org.springframework.util.Assert; +import org.springframework.web.context.WebApplicationContext; + +/** + * {@code MockMvcTester} provides support for testing Spring MVC applications + * with {@link MockMvc} for server request handling using + * {@linkplain org.assertj.core.api.Assertions AssertJ}. + * + *

    A tester instance can be created from a {@link WebApplicationContext}: + *

    
    + * // Create an instance with default settings
    + * MockMvcTester mvc = MockMvcTester.from(applicationContext);
    + *
    + * // Create an instance with a custom Filter
    + * MockMvcTester mvc = MockMvcTester.from(applicationContext,
    + *         builder -> builder.addFilters(filter).build());
    + * 
    + * + *

    A tester can be created in standalone mode by providing the controller + * instances to include:

    
    + * // Create an instance for PersonController
    + * MockMvcTester mvc = MockMvcTester.of(new PersonController());
    + * 
    + * + *

    Simple, single-statement assertions can be done wrapping the request + * builder in {@code assertThat()} provides access to assertions. For instance: + *

    
    + * // perform a GET on /hi and assert the response body is equal to Hello
    + * assertThat(mvc.get().uri("/hi")).hasStatusOk().hasBodyTextEqualTo("Hello");
    + * 
    + * + *

    For more complex scenarios the {@linkplain MvcTestResult result} of the + * exchange can be assigned in a variable to run multiple assertions: + *

    
    + * // perform a POST on /save and assert the response body is empty
    + * MvcTestResult result = mvc.post().uri("/save").exchange();
    + * assertThat(result).hasStatus(HttpStatus.CREATED);
    + * assertThat(result).body().isEmpty();
    + * 
    + * + *

    If the request is processing asynchronously, {@code exchange} waits for + * its completion, either using the + * {@linkplain org.springframework.mock.web.MockAsyncContext#setTimeout default + * timeout} or a given one. If you prefer to get the result of an + * asynchronous request immediately, use {@code asyncExchange}: + *

    
    + * // perform a POST on /save and assert an asynchronous request has started
    + * assertThat(mvc.post().uri("/save").asyncExchange()).request().hasAsyncStarted();
    + * 
    + * + *

    You can also perform requests using the static builders approach that + * {@link MockMvc} uses. For instance:

    
    + * // perform a GET on /hi and assert the response body is equal to Hello
    + * assertThat(mvc.perform(get("/hi")))
    + *         .hasStatusOk().hasBodyTextEqualTo("Hello");
    + * 
    + * + *

    Use this approach if you have a custom {@link RequestBuilder} implementation + * that you'd like to integrate here. This approach is also invoking {@link MockMvc} + * without any additional processing of asynchronous requests. + * + *

    One main difference between {@link MockMvc} and {@code MockMvcTester} is + * that an unresolved exception is not thrown directly when using + * {@code MockMvcTester}. Rather an {@link MvcTestResult} is available with an + * {@linkplain MvcTestResult#getUnresolvedException() unresolved exception}. + * Both resolved and unresolved exceptions are considered a failure that can + * be asserted as follows: + *

    
    + * // perform a GET on /boom and assert the message for the exception
    + * assertThat(mvc.get().uri("/boom")).hasFailed()
    + *         .failure().hasMessage("Test exception");
    + * 
    + * + *

    Any attempt to access the result with an unresolved exception will + * throw an {@link AssertionError}: + *

    
    + * // throw an AssertionError with an unresolved exception
    + * assertThat(mvc.get().uri("/boom")).hasStatus5xxServerError();
    + * 
    + * + *

    {@code MockMvcTester} can be configured with a list of + * {@linkplain HttpMessageConverter message converters} to allow the response + * body to be deserialized, rather than asserting on the raw values. + * + * @author Stephane Nicoll + * @author Brian Clozel + * @since 6.2 + */ +public final class MockMvcTester { + + private final MockMvc mockMvc; + + @Nullable + private final HttpMessageContentConverter contentConverter; + + + private MockMvcTester(MockMvc mockMvc, @Nullable HttpMessageContentConverter contentConverter) { + Assert.notNull(mockMvc, "mockMVC should not be null"); + this.mockMvc = mockMvc; + this.contentConverter = contentConverter; + } + + /** + * Create an instance that delegates to the given {@link MockMvc} instance. + * @param mockMvc the MockMvc instance to delegate calls to + */ + public static MockMvcTester create(MockMvc mockMvc) { + return new MockMvcTester(mockMvc, null); + } + + /** + * Create an instance using the given, fully initialized (i.e., + * refreshed) {@link WebApplicationContext}. The given + * {@code customizations} are applied to the {@link DefaultMockMvcBuilder} + * that ultimately creates the underlying {@link MockMvc} instance. + *

    If no further customization of the underlying {@link MockMvc} instance + * is required, use {@link #from(WebApplicationContext)}. + * @param applicationContext the application context to detect the Spring + * MVC infrastructure and application controllers from + * @param customizations a function that creates a {@link MockMvc} + * instance based on a {@link DefaultMockMvcBuilder} + * @see MockMvcBuilders#webAppContextSetup(WebApplicationContext) + */ + public static MockMvcTester from(WebApplicationContext applicationContext, + Function customizations) { + + DefaultMockMvcBuilder builder = MockMvcBuilders.webAppContextSetup(applicationContext); + MockMvc mockMvc = customizations.apply(builder); + return create(mockMvc); + } + + /** + * Shortcut to create an instance using the given fully initialized (i.e., + * refreshed) {@link WebApplicationContext}. + *

    Consider using {@link #from(WebApplicationContext, Function)} if + * further customization of the underlying {@link MockMvc} instance is + * required. + * @param applicationContext the application context to detect the Spring + * MVC infrastructure and application controllers from + * @see MockMvcBuilders#webAppContextSetup(WebApplicationContext) + */ + public static MockMvcTester from(WebApplicationContext applicationContext) { + return from(applicationContext, DefaultMockMvcBuilder::build); + } + + /** + * Create an instance by registering one or more {@code @Controller} instances + * and configuring Spring MVC infrastructure programmatically. + *

    This allows full control over the instantiation and initialization of + * controllers and their dependencies, similar to plain unit tests while + * also making it possible to test one controller at a time. + * @param controllers one or more {@code @Controller} instances or + * {@code @Controller} types to test; a type ({@code Class}) will be turned + * into an instance + * @param customizations a function that creates a {@link MockMvc} instance + * based on a {@link StandaloneMockMvcBuilder}, typically to configure the + * Spring MVC infrastructure + * @see MockMvcBuilders#standaloneSetup(Object...) + */ + public static MockMvcTester of(Collection controllers, + Function customizations) { + + StandaloneMockMvcBuilder builder = MockMvcBuilders.standaloneSetup(controllers.toArray()); + return create(customizations.apply(builder)); + } + + /** + * Shortcut to create an instance by registering one or more {@code @Controller} + * instances. + *

    The minimum infrastructure required by the + * {@link org.springframework.web.servlet.DispatcherServlet DispatcherServlet} + * to serve requests with annotated controllers is created. Consider using + * {@link #of(Collection, Function)} if additional configuration of the MVC + * infrastructure is required. + * @param controllers one or more {@code @Controller} instances or + * {@code @Controller} types to test; a type ({@code Class}) will be turned + * into an instance + * @see MockMvcBuilders#standaloneSetup(Object...) + */ + public static MockMvcTester of(Object... controllers) { + return of(Arrays.asList(controllers), StandaloneMockMvcBuilder::build); + } + + /** + * Return a new instance using the specified {@linkplain HttpMessageConverter + * message converters}. + *

    If none are specified, only basic assertions on the response body can + * be performed. Consider registering a suitable JSON converter for asserting + * against JSON data structures. + * @param httpMessageConverters the message converters to use + * @return a new instance using the specified converters + */ + public MockMvcTester withHttpMessageConverters(Iterable> httpMessageConverters) { + return new MockMvcTester(this.mockMvc, HttpMessageContentConverter.of(httpMessageConverters)); + } + + /** + * Prepare an HTTP GET request. + *

    The returned builder can be wrapped in {@code assertThat} to enable + * assertions on the result. For multi-statements assertions, use + * {@link MockMvcRequestBuilder#exchange() exchange()} to assign the + * result. To control the time to wait for asynchronous request to complete + * on a per-request basis, use + * {@link MockMvcRequestBuilder#exchange(Duration) exchange(Duration)}. + * @return a request builder for specifying the target URI + */ + public MockMvcRequestBuilder get() { + return method(HttpMethod.GET); + } + + /** + * Prepare an HTTP HEAD request. + *

    The returned builder can be wrapped in {@code assertThat} to enable + * assertions on the result. For multi-statements assertions, use + * {@link MockMvcRequestBuilder#exchange() exchange()} to assign the + * result. To control the time to wait for asynchronous request to complete + * on a per-request basis, use + * {@link MockMvcRequestBuilder#exchange(Duration) exchange(Duration)}. + * @return a request builder for specifying the target URI + */ + public MockMvcRequestBuilder head() { + return method(HttpMethod.HEAD); + } + + /** + * Prepare an HTTP POST request. + *

    The returned builder can be wrapped in {@code assertThat} to enable + * assertions on the result. For multi-statements assertions, use + * {@link MockMvcRequestBuilder#exchange() exchange()} to assign the + * result. To control the time to wait for asynchronous request to complete + * on a per-request basis, use + * {@link MockMvcRequestBuilder#exchange(Duration) exchange(Duration)}. + * @return a request builder for specifying the target URI + */ + public MockMvcRequestBuilder post() { + return method(HttpMethod.POST); + } + + /** + * Prepare an HTTP PUT request. + *

    The returned builder can be wrapped in {@code assertThat} to enable + * assertions on the result. For multi-statements assertions, use + * {@link MockMvcRequestBuilder#exchange() exchange()} to assign the + * result. To control the time to wait for asynchronous request to complete + * on a per-request basis, use + * {@link MockMvcRequestBuilder#exchange(Duration) exchange(Duration)}. + * @return a request builder for specifying the target URI + */ + public MockMvcRequestBuilder put() { + return method(HttpMethod.PUT); + } + + /** + * Prepare an HTTP PATCH request. + *

    The returned builder can be wrapped in {@code assertThat} to enable + * assertions on the result. For multi-statements assertions, use + * {@link MockMvcRequestBuilder#exchange() exchange()} to assign the + * result. To control the time to wait for asynchronous request to complete + * on a per-request basis, use + * {@link MockMvcRequestBuilder#exchange(Duration) exchange(Duration)}. + * @return a request builder for specifying the target URI + */ + public MockMvcRequestBuilder patch() { + return method(HttpMethod.PATCH); + } + + /** + * Prepare an HTTP DELETE request. + *

    The returned builder can be wrapped in {@code assertThat} to enable + * assertions on the result. For multi-statements assertions, use + * {@link MockMvcRequestBuilder#exchange() exchange()} to assign the + * result. To control the time to wait for asynchronous request to complete + * on a per-request basis, use + * {@link MockMvcRequestBuilder#exchange(Duration) exchange(Duration)}. + * @return a request builder for specifying the target URI + */ + public MockMvcRequestBuilder delete() { + return method(HttpMethod.DELETE); + } + + /** + * Prepare an HTTP OPTIONS request. + *

    The returned builder can be wrapped in {@code assertThat} to enable + * assertions on the result. For multi-statements assertions, use + * {@link MockMvcRequestBuilder#exchange() exchange()} to assign the + * result. To control the time to wait for asynchronous request to complete + * on a per-request basis, use + * {@link MockMvcRequestBuilder#exchange(Duration) exchange(Duration)}. + * @return a request builder for specifying the target URI + */ + public MockMvcRequestBuilder options() { + return method(HttpMethod.OPTIONS); + } + + /** + * Prepare a request for the specified {@code HttpMethod}. + *

    The returned builder can be wrapped in {@code assertThat} to enable + * assertions on the result. For multi-statements assertions, use + * {@link MockMvcRequestBuilder#exchange() exchange()} to assign the + * result. To control the time to wait for asynchronous request to complete + * on a per-request basis, use + * {@link MockMvcRequestBuilder#exchange(Duration) exchange(Duration)}. + * @return a request builder for specifying the target URI + */ + public MockMvcRequestBuilder method(HttpMethod method) { + return new MockMvcRequestBuilder(method); + } + + /** + * Perform a request using the given {@link RequestBuilder} and return a + * {@link MvcTestResult result} that can be used with standard + * {@link org.assertj.core.api.Assertions AssertJ} assertions. + *

    Use only this method if you need to provide a custom + * {@link RequestBuilder}. For regular cases, users should initiate the + * configuration of the request using one of the methods available on + * this instance, for example, {@link #get()} for HTTP GET. + *

    Contrary to {@link MockMvc#perform(RequestBuilder)}, this does not + * throw an exception if the request fails with an unresolved exception. + * Rather, the result provides the exception, if any. Assuming that a + * {@link MockMvcRequestBuilders#post(URI) POST} request against + * {@code /boom} throws an {@code IllegalStateException}, the following + * asserts that the invocation has indeed failed with the expected error + * message: + *

    assertThat(mvc.post().uri("/boom")))
    +	 *       .failure().isInstanceOf(IllegalStateException.class)
    +	 *       .hasMessage("Expected");
    +	 * 
    + * @param requestBuilder used to prepare the request to execute + * @return an {@link MvcTestResult} to be wrapped in {@code assertThat} + * @see MockMvc#perform(RequestBuilder) + * @see #method(HttpMethod) + */ + public MvcTestResult perform(RequestBuilder requestBuilder) { + Object result = getMvcResultOrFailure(requestBuilder); + if (result instanceof MvcResult mvcResult) { + return new DefaultMvcTestResult(mvcResult, null, this.contentConverter); + } + else { + return new DefaultMvcTestResult(null, (Exception) result, this.contentConverter); + } + } + + private Object getMvcResultOrFailure(RequestBuilder requestBuilder) { + try { + return this.mockMvc.perform(requestBuilder).andReturn(); + } + catch (Exception ex) { + return ex; + } + } + + /** + * Execute the request using the specified {@link RequestBuilder}. If the + * request is processing asynchronously, wait at most the given + * {@code timeToWait} duration. If not specified, then fall back on the + * timeout value associated with the async request, see + * {@link org.springframework.mock.web.MockAsyncContext#setTimeout}. + */ + MvcTestResult exchange(RequestBuilder requestBuilder, @Nullable Duration timeToWait) { + MvcTestResult result = perform(requestBuilder); + if (result.getUnresolvedException() == null) { + if (result.getRequest().isAsyncStarted()) { + // Wait for async result before dispatching + long waitMs = (timeToWait != null ? timeToWait.toMillis() : -1); + result.getMvcResult().getAsyncResult(waitMs); + + // Perform ASYNC dispatch + RequestBuilder dispatchRequest = servletContext -> { + MockHttpServletRequest request = result.getMvcResult().getRequest(); + request.setDispatcherType(DispatcherType.ASYNC); + request.setAsyncStarted(false); + return request; + }; + return perform(dispatchRequest); + } + } + return result; + } + + + /** + * A builder for {@link MockHttpServletRequest} that supports AssertJ. + */ + public final class MockMvcRequestBuilder extends AbstractMockHttpServletRequestBuilder + implements AssertProvider { + + private final HttpMethod httpMethod; + + private MockMvcRequestBuilder(HttpMethod httpMethod) { + super(httpMethod); + this.httpMethod = httpMethod; + } + + /** + * Enable file upload support using multipart. + * @return a {@link MockMultipartMvcRequestBuilder} with the settings + * configured thus far + */ + public MockMultipartMvcRequestBuilder multipart() { + return new MockMultipartMvcRequestBuilder(this); + } + + /** + * Execute the request. If the request is processing asynchronously, + * wait at most the given timeout value associated with the async request, + * see {@link org.springframework.mock.web.MockAsyncContext#setTimeout}. + *

    For simple assertions, you can wrap this builder in + * {@code assertThat} rather than calling this method explicitly: + *

    
    +		 * // These two examples are equivalent
    +		 * assertThat(mvc.get().uri("/greet")).hasStatusOk();
    +		 * assertThat(mvc.get().uri("/greet").exchange()).hasStatusOk();
    +		 * 
    + *

    For assertions on the original asynchronous request that might + * still be in progress, use {@link #asyncExchange()}. + * @see #exchange(Duration) to customize the timeout for async requests + */ + public MvcTestResult exchange() { + return MockMvcTester.this.exchange(this, null); + } + + /** + * Execute the request and wait at most the given {@code timeToWait} + * duration for the asynchronous request to complete. If the request + * is not asynchronous, the {@code timeToWait} is ignored. + *

    For assertions on the original asynchronous request that might + * still be in progress, use {@link #asyncExchange()}. + * @see #exchange() + */ + public MvcTestResult exchange(Duration timeToWait) { + return MockMvcTester.this.exchange(this, timeToWait); + } + + /** + * Execute the request and do not attempt to wait for the completion of + * an asynchronous request. Contrary to {@link #exchange()}, this returns + * the original result that might still be in progress. + */ + public MvcTestResult asyncExchange() { + return MockMvcTester.this.perform(this); + } + + @Override + public MvcTestResultAssert assertThat() { + return new MvcTestResultAssert(exchange(), MockMvcTester.this.contentConverter); + } + } + + /** + * A builder for {@link MockMultipartHttpServletRequest} that supports AssertJ. + */ + public final class MockMultipartMvcRequestBuilder + extends AbstractMockMultipartHttpServletRequestBuilder + implements AssertProvider { + + private MockMultipartMvcRequestBuilder(MockMvcRequestBuilder currentBuilder) { + super(currentBuilder.httpMethod); + merge(currentBuilder); + } + + /** + * Execute the request. If the request is processing asynchronously, + * wait at most the given timeout value associated with the async request, + * see {@link org.springframework.mock.web.MockAsyncContext#setTimeout}. + *

    For simple assertions, you can wrap this builder in + * {@code assertThat} rather than calling this method explicitly: + *

    
    +		 * // These two examples are equivalent
    +		 * assertThat(mvc.get().uri("/greet")).hasStatusOk();
    +		 * assertThat(mvc.get().uri("/greet").exchange()).hasStatusOk();
    +		 * 
    + *

    For assertions on the original asynchronous request that might + * still be in progress, use {@link #asyncExchange()}. + * @see #exchange(Duration) to customize the timeout for async requests + */ + public MvcTestResult exchange() { + return MockMvcTester.this.exchange(this, null); + } + + /** + * Execute the request and wait at most the given {@code timeToWait} + * duration for the asynchronous request to complete. If the request + * is not asynchronous, the {@code timeToWait} is ignored. + *

    For assertions on the original asynchronous request that might + * still be in progress, use {@link #asyncExchange()}. + * @see #exchange() + */ + public MvcTestResult exchange(Duration timeToWait) { + return MockMvcTester.this.exchange(this, timeToWait); + } + + /** + * Execute the request and do not attempt to wait for the completion of + * an asynchronous request. Contrary to {@link #exchange()}, this returns + * the original result that might still be in progress. + */ + public MvcTestResult asyncExchange() { + return MockMvcTester.this.perform(this); + } + + @Override + public MvcTestResultAssert assertThat() { + return new MvcTestResultAssert(exchange(), MockMvcTester.this.contentConverter); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/ModelAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/ModelAssert.java new file mode 100644 index 000000000000..73bcebdacccb --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/ModelAssert.java @@ -0,0 +1,163 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; + +import org.assertj.core.api.AbstractMapAssert; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.internal.Failures; + +import org.springframework.lang.Nullable; +import org.springframework.test.validation.AbstractBindingResultAssert; +import org.springframework.validation.BindingResult; +import org.springframework.validation.BindingResultUtils; +import org.springframework.validation.Errors; +import org.springframework.web.servlet.ModelAndView; + +/** + * AssertJ {@linkplain org.assertj.core.api.Assert assertions} that can be applied + * to a {@linkplain ModelAndView#getModel() model}. + * + * @author Stephane Nicoll + * @since 6.2 + */ +public class ModelAssert extends AbstractMapAssert, String, Object> { + + private final Failures failures = Failures.instance(); + + public ModelAssert(Map map) { + super(map, ModelAssert.class); + } + + /** + * Return a new {@linkplain AbstractBindingResultAssert assertion} object + * that uses the {@link BindingResult} with the given {@code name} as the + * object to test. + *

    Example:

    
    +	 * // Check that the "person" attribute in the model has 2 errors:
    +	 * assertThat(...).model().extractingBindingResult("person").hasErrorsCount(2);
    +	 * 
    + */ + public AbstractBindingResultAssert extractingBindingResult(String name) { + BindingResult result = BindingResultUtils.getBindingResult(this.actual, name); + if (result == null) { + throw unexpectedModel("to have a binding result for attribute '%s'", name); + } + return new BindingResultAssert(name, result); + } + + /** + * Verify that the actual model has at least one error. + */ + public ModelAssert hasErrors() { + if (getAllErrors() == 0) { + throw unexpectedModel("to have at least one error"); + } + return this.myself; + } + + /** + * Verify that the actual model does not have any errors. + */ + public ModelAssert doesNotHaveErrors() { + int count = getAllErrors(); if (count > 0) { + throw unexpectedModel("to not have an error, but got %s", count); + } + return this.myself; + } + + /** + * Verify that the actual model contains the attributes with the given + * {@code names}, and that each of these attributes has each at least one error. + * @param names the expected names of attributes with errors + */ + public ModelAssert hasAttributeErrors(String... names) { + return assertAttributes(names, BindingResult::hasErrors, + "to have attribute errors for", "these attributes do not have any errors"); + } + + /** + * Verify that the actual model contains the attributes with the given + * {@code names}, and that none of these attributes has an error. + * @param names the expected names of attributes without errors + */ + public ModelAssert doesNotHaveAttributeErrors(String... names) { + return assertAttributes(names, Predicate.not(BindingResult::hasErrors), + "to have attribute without errors for", "these attributes have at least one error"); + } + + private ModelAssert assertAttributes(String[] names, Predicate condition, + String assertionMessage, String failAssertionMessage) { + + Set missing = new LinkedHashSet<>(); + Set failCondition = new LinkedHashSet<>(); + for (String name : names) { + BindingResult bindingResult = getBindingResult(name); + if (bindingResult == null) { + missing.add(name); + } + else if (!condition.test(bindingResult)) { + failCondition.add(name); + } + } + if (!missing.isEmpty() || !failCondition.isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append("%n%s:%n %s%n".formatted(assertionMessage, String.join(", ", names))); + if (!missing.isEmpty()) { + sb.append("%nbut could not find these attributes:%n %s%n".formatted(String.join(", ", missing))); + } + if (!failCondition.isEmpty()) { + String prefix = missing.isEmpty() ? "but" : "and"; + sb.append("%n%s %s:%n %s%n".formatted(prefix, failAssertionMessage, String.join(", ", failCondition))); + } + throw unexpectedModel(sb.toString()); + } + return this.myself; + } + + private AssertionError unexpectedModel(String reason, Object... arguments) { + return this.failures.failure(this.info, new UnexpectedModel(reason, arguments)); + } + + private int getAllErrors() { + return this.actual.values().stream().filter(Errors.class::isInstance).map(Errors.class::cast) + .map(Errors::getErrorCount).reduce(0, Integer::sum); + } + + @Nullable + private BindingResult getBindingResult(String name) { + return BindingResultUtils.getBindingResult(this.actual, name); + } + + private final class UnexpectedModel extends BasicErrorMessageFactory { + + private UnexpectedModel(String reason, Object... arguments) { + super("%nExpecting model:%n %s%n%s", ModelAssert.this.actual, reason.formatted(arguments)); + } + } + + private static final class BindingResultAssert extends AbstractBindingResultAssert { + public BindingResultAssert(String name, BindingResult bindingResult) { + super(name, bindingResult, BindingResultAssert.class); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcTestResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcTestResult.java new file mode 100644 index 000000000000..20b6e942db15 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcTestResult.java @@ -0,0 +1,83 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import org.assertj.core.api.AssertProvider; + +import org.springframework.lang.Nullable; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.servlet.MvcResult; + +/** + * Provides the result of an executed request using {@link MockMvcTester} that + * is meant to be used with {@link org.assertj.core.api.Assertions#assertThat(AssertProvider) + * assertThat}. + * + *

    Can be in one of two distinct states: + *

      + *
    1. The request processed successfully, even if it failed with an exception + * that has been resolved. The {@linkplain #getMvcResult() result} is available, + * and {@link #getUnresolvedException()} will return {@code null}.
    2. + *
    3. The request failed unexpectedly. {@link #getUnresolvedException()} + * provides more information about the error, and any attempt to access the + * {@linkplain #getMvcResult() result} will fail with an exception.
    4. + *
    + * + *

    If the request was asynchronous, it is fully resolved at this point and + * regular assertions can be applied without having to wait for the completion + * of the response. + * + * @author Stephane Nicoll + * @author Brian Clozel + * @since 6.2 + * @see MockMvcTester + */ +public interface MvcTestResult extends AssertProvider { + + /** + * Return the {@linkplain MvcResult result} of the processing. If + * the processing has failed with an unresolved exception, the + * result is not available, see {@link #getUnresolvedException()}. + * @return the {@link MvcResult} + * @throws IllegalStateException if the processing has failed with + * an unresolved exception + */ + MvcResult getMvcResult(); + + /** + * Return the performed {@linkplain MockHttpServletRequest request}. + */ + default MockHttpServletRequest getRequest() { + return getMvcResult().getRequest(); + } + + /** + * Return the resulting {@linkplain MockHttpServletResponse response}. + */ + default MockHttpServletResponse getResponse() { + return getMvcResult().getResponse(); + } + + /** + * Return the exception that was thrown unexpectedly while processing the + * request, if any. + */ + @Nullable + Exception getUnresolvedException(); + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcTestResultAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcTestResultAssert.java new file mode 100644 index 000000000000..55fd9e0ec6ad --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcTestResultAssert.java @@ -0,0 +1,270 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.io.BufferedReader; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.io.StringReader; +import java.io.StringWriter; +import java.io.Writer; + +import jakarta.servlet.http.Cookie; +import org.assertj.core.api.AbstractStringAssert; +import org.assertj.core.api.AbstractThrowableAssert; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.MapAssert; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.internal.Failures; + +import org.springframework.lang.Nullable; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.http.HttpMessageContentConverter; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultHandler; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.result.MockMvcResultHandlers; +import org.springframework.web.servlet.ModelAndView; + +/** + * AssertJ {@linkplain org.assertj.core.api.Assert assertions} that can be applied + * to {@link MvcTestResult}. + * + * @author Stephane Nicoll + * @author Brian Clozel + * @since 6.2 + */ +public class MvcTestResultAssert extends AbstractMockHttpServletResponseAssert { + + MvcTestResultAssert(MvcTestResult actual, @Nullable HttpMessageContentConverter contentConverter) { + super(contentConverter, actual, MvcTestResultAssert.class); + } + + @Override + protected MockHttpServletResponse getResponse() { + return getMvcResult().getResponse(); + } + + /** + * Verify that the request has failed and return a new + * {@linkplain AbstractThrowableAssert assertion} object that uses the + * failure as the object to test. + */ + public AbstractThrowableAssert failure() { + hasFailed(); + return Assertions.assertThat(getFailure()); + } + + /** + * Return a new {@linkplain AbstractMockHttpServletRequestAssert assertion} + * object that uses the {@link MockHttpServletRequest} as the object to test. + */ + public AbstractMockHttpServletRequestAssert request() { + return new MockHttpRequestAssert(getMvcResult().getRequest()); + } + + /** + * Return a new {@linkplain CookieMapAssert assertion} object that uses the + * response's {@linkplain Cookie cookies} as the object to test. + */ + public CookieMapAssert cookies() { + return new CookieMapAssert(getMvcResult().getResponse().getCookies()); + } + + /** + * Return a new {@linkplain HandlerResultAssert assertion} object that uses + * the handler as the object to test. + *

    For a method invocation on a controller, this is a relative method handler. + *

    Example:

    
    +	 * // Check that a GET to "/greet" is invoked on a "handleGreet" method name
    +	 * assertThat(mvc.perform(get("/greet")).handler().method().hasName("handleGreet");
    +	 * 
    + */ + public HandlerResultAssert handler() { + return new HandlerResultAssert(getMvcResult().getHandler()); + } + + /** + * Verify that a {@link ModelAndView} is available and return a new + * {@linkplain ModelAssert assertion} object that uses the + * {@linkplain ModelAndView#getModel() model} as the object to test. + */ + public ModelAssert model() { + return new ModelAssert(getModelAndView().getModel()); + } + + /** + * Verify that a {@link ModelAndView} is available and return a new + * {@linkplain AbstractStringAssert assertion} object that uses the + * {@linkplain ModelAndView#getViewName() view name} as the object to test. + * @see #hasViewName(String) + */ + public AbstractStringAssert viewName() { + return Assertions.assertThat(getModelAndView().getViewName()).as("View name"); + } + + /** + * Return a new {@linkplain MapAssert assertion} object that uses the + * "output" flash attributes saved during request processing as the object + * to test. + */ + public MapAssert flash() { + return new MapAssert<>(getMvcResult().getFlashMap()); + } + + /** + * Print {@link MvcResult} details to {@code System.out}. + *

    You must call it before calling the assertion otherwise it is ignored + * as the failing assertion breaks the chained call by throwing an + * AssertionError. + */ + public MvcTestResultAssert debug() { + return debug(System.out); + } + + /** + * Print {@link MvcResult} details to the supplied {@link OutputStream}. + *

    You must call it before calling the assertion otherwise it is ignored + * as the failing assertion breaks the chained call by throwing an + * AssertionError. + */ + public MvcTestResultAssert debug(OutputStream stream) { + return apply(MockMvcResultHandlers.print(stream)); + } + + /** + * Print {@link MvcResult} details to the supplied {@link Writer}. + *

    You must call it before calling the assertion otherwise it is ignored + * as the failing assertion breaks the chained call by throwing an + * AssertionError. + */ + public MvcTestResultAssert debug(Writer writer) { + return apply(MockMvcResultHandlers.print(writer)); + } + + /** + * Verify that the request has failed. + */ + public MvcTestResultAssert hasFailed() { + Assertions.assertThat(getFailure()) + .withFailMessage("Expected request to fail, but it succeeded").isNotNull(); + return this; + } + + /** + * Verify that the request has not failed. + */ + public MvcTestResultAssert doesNotHaveFailed() { + Assertions.assertThat(getFailure()) + .withFailMessage("Expected request to succeed, but it failed").isNull(); + return this; + } + + /** + * Verify that the actual MVC result matches the given {@link ResultMatcher}. + * @param resultMatcher the result matcher to invoke + */ + public MvcTestResultAssert matches(ResultMatcher resultMatcher) { + MvcResult mvcResult = getMvcResult(); + return super.satisfies(mvcTestResult -> resultMatcher.match(mvcResult)); + } + + /** + * Apply the given {@link ResultHandler} to the actual MVC result. + * @param resultHandler the result matcher to invoke + */ + public MvcTestResultAssert apply(ResultHandler resultHandler) { + MvcResult mvcResult = getMvcResult(); + return satisfies(mvcTestResult -> resultHandler.handle(mvcResult)); + } + + /** + * Verify that a {@link ModelAndView} is available with a view name equal to + * the given one. + *

    For more advanced assertions, consider using {@link #viewName()}. + * @param viewName the expected view name + */ + public MvcTestResultAssert hasViewName(String viewName) { + viewName().isEqualTo(viewName); + return this.myself; + } + + @Nullable + private Throwable getFailure() { + Exception unresolvedException = this.actual.getUnresolvedException(); + if (unresolvedException != null) { + return unresolvedException; + } + return this.actual.getMvcResult().getResolvedException(); + } + + @SuppressWarnings("NullAway") + private ModelAndView getModelAndView() { + ModelAndView modelAndView = getMvcResult().getModelAndView(); + Assertions.assertThat(modelAndView).as("ModelAndView").isNotNull(); + return modelAndView; + } + + protected MvcResult getMvcResult() { + Exception unresolvedException = this.actual.getUnresolvedException(); + if (unresolvedException != null) { + throw Failures.instance().failure(this.info, + new RequestFailedUnexpectedly(unresolvedException)); + } + return this.actual.getMvcResult(); + } + + private static final class MockHttpRequestAssert extends AbstractMockHttpServletRequestAssert { + + private MockHttpRequestAssert(MockHttpServletRequest request) { + super(request, MockHttpRequestAssert.class); + } + } + + private static final class RequestFailedUnexpectedly extends BasicErrorMessageFactory { + + private RequestFailedUnexpectedly(Exception ex) { + super("%nRequest failed unexpectedly:%n%s", unquotedString(getIndentedStackTraceAsString(ex))); + } + + private static String getIndentedStackTraceAsString(Throwable ex) { + String stackTrace = getStackTraceAsString(ex); + return indent(stackTrace); + } + + private static String getStackTraceAsString(Throwable ex) { + StringWriter writer = new StringWriter(); + PrintWriter printer = new PrintWriter(writer); + ex.printStackTrace(printer); + return writer.toString(); + } + + private static String indent(String input) { + BufferedReader reader = new BufferedReader(new StringReader(input)); + StringWriter writer = new StringWriter(); + PrintWriter printer = new PrintWriter(writer); + reader.lines().forEach(line -> { + printer.print(" "); + printer.println(line); + }); + return writer.toString(); + } + + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/package-info.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/package-info.java new file mode 100644 index 000000000000..6fe626a51659 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/package-info.java @@ -0,0 +1,9 @@ +/** + * AssertJ support for MockMvc. + */ +@NonNullApi +@NonNullFields +package org.springframework.test.web.servlet.assertj; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcHttpConnector.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcHttpConnector.java index 36a49b58c2bf..e387a3b65e89 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcHttpConnector.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcHttpConnector.java @@ -54,6 +54,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.RequestBuilder; +import org.springframework.test.web.servlet.request.AbstractMockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -134,7 +135,7 @@ private RequestBuilder adaptRequest( // Initialize the client request requestCallback.apply(httpRequest).block(TIMEOUT); - MockHttpServletRequestBuilder requestBuilder = + AbstractMockHttpServletRequestBuilder requestBuilder = initRequestBuilder(httpMethod, uri, httpRequest, contentRef.get()); requestBuilder.headers(httpRequest.getHeaders()); @@ -149,7 +150,7 @@ private RequestBuilder adaptRequest( return requestBuilder; } - private MockHttpServletRequestBuilder initRequestBuilder( + private AbstractMockHttpServletRequestBuilder initRequestBuilder( HttpMethod httpMethod, URI uri, MockClientHttpRequest httpRequest, @Nullable byte[] bytes) { String contentType = httpRequest.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE); @@ -208,6 +209,7 @@ private MockClientHttpResponse adaptResponse(MvcResult mvcResult) { .path(cookie.getPath()) .secure(cookie.getSecure()) .httpOnly(cookie.isHttpOnly()) + .partitioned(cookie.getAttribute("Partitioned") != null) .sameSite(cookie.getAttribute("samesite")) .build(); clientResponse.getCookies().add(httpCookie.getName(), httpCookie); diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClient.java index 82ad85870977..cb632fa6e28b 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,7 @@ import org.springframework.test.web.servlet.ResultMatcher; import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; import org.springframework.test.web.servlet.setup.MockMvcConfigurer; +import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; import org.springframework.validation.Validator; import org.springframework.web.accept.ContentNegotiationManager; @@ -47,6 +48,7 @@ import org.springframework.web.servlet.LocaleResolver; import org.springframework.web.servlet.View; import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import org.springframework.web.util.pattern.PathPatternParser; @@ -81,13 +83,25 @@ public interface MockMvcWebTestClient { * Begin creating a {@link WebTestClient} by providing the {@code @Controller} * instance(s) to handle requests with. *

    Internally this is delegated to and equivalent to using - * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#standaloneSetup(Object...)}. + * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#standaloneSetup(Object...)} * to initialize {@link MockMvc}. */ static ControllerSpec bindToController(Object... controllers) { return new StandaloneMockMvcSpec(controllers); } + /** + * Begin creating a {@link WebTestClient} by providing the {@link RouterFunction} + * instance(s) to handle requests with. + *

    Internally this is delegated to and equivalent to using + * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#routerFunctions(RouterFunction[])} + * to initialize {@link MockMvc}. + * @since 6.2 + */ + static RouterFunctionSpec bindToRouterFunction(RouterFunction... routerFunctions) { + return new RouterFunctionMockMvcSpec(routerFunctions); + } + /** * Begin creating a {@link WebTestClient} by providing a * {@link WebApplicationContext} with Spring MVC infrastructure and @@ -116,7 +130,7 @@ static WebTestClient.Builder bindTo(MockMvc mockMvc) { * including HTTP status, headers, and body. That is all that is available * when making a live request over HTTP. However when the server is * {@link MockMvc}, many more assertions are possible against the server - * response, e.g. model attributes, flash attributes, etc. + * response, for example, model attributes, flash attributes, etc. * *

    Example: *

    @@ -381,4 +395,72 @@ ControllerSpec mappedInterceptors(
     		ControllerSpec customHandlerMapping(Supplier factory);
     	}
     
    +
    +	/**
    +	 * Specification for configuring {@link MockMvc} to test one or more
    +	 * {@linkplain RouterFunction router functions}
    +	 * directly, and a simple facade around {@link RouterFunctionMockMvcBuilder}.
    +	 * @since 6.2
    +	 */
    +	interface RouterFunctionSpec extends MockMvcServerSpec {
    +
    +		/**
    +		 * Set the message converters to use.
    +		 * 

    This is delegated to + * {@link RouterFunctionMockMvcBuilder#setMessageConverters(HttpMessageConverter[])}. + */ + RouterFunctionSpec messageConverters(HttpMessageConverter... messageConverters); + + /** + * Add global interceptors. + *

    This is delegated to + * {@link RouterFunctionMockMvcBuilder#addInterceptors(HandlerInterceptor...)}. + */ + RouterFunctionSpec interceptors(HandlerInterceptor... interceptors); + + /** + * Add interceptors for specific patterns. + *

    This is delegated to + * {@link RouterFunctionMockMvcBuilder#addMappedInterceptors(String[], HandlerInterceptor...)}. + */ + RouterFunctionSpec mappedInterceptors( + @Nullable String[] pathPatterns, HandlerInterceptor... interceptors); + + /** + * Specify the timeout value for async execution. + *

    This is delegated to + * {@link RouterFunctionMockMvcBuilder#setAsyncRequestTimeout(long)}. + */ + RouterFunctionSpec asyncRequestTimeout(long timeout); + + /** + * Set the HandlerExceptionResolver types to use. + *

    This is delegated to + * {@link RouterFunctionMockMvcBuilder#setHandlerExceptionResolvers(HandlerExceptionResolver...)}. + */ + RouterFunctionSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers); + + /** + * Set up view resolution. + *

    This is delegated to + * {@link RouterFunctionMockMvcBuilder#setViewResolvers(ViewResolver...)}. + */ + RouterFunctionSpec viewResolvers(ViewResolver... resolvers); + + /** + * Set up a single {@link ViewResolver} with a fixed view. + *

    This is delegated to + * {@link RouterFunctionMockMvcBuilder#setSingleView(View)}. + */ + RouterFunctionSpec singleView(View view); + + /** + * Enable URL path matching with parsed + * {@link org.springframework.web.util.pattern.PathPattern PathPatterns}. + *

    This is delegated to + * {@link RouterFunctionMockMvcBuilder#setPatternParser(PathPatternParser)}. + */ + RouterFunctionSpec patternParser(PathPatternParser parser); + } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RouterFunctionMockMvcSpec.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RouterFunctionMockMvcSpec.java new file mode 100644 index 000000000000..5ff3e1dfc2b8 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RouterFunctionMockMvcSpec.java @@ -0,0 +1,100 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.client; + +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * Simple wrapper around a {@link RouterFunctionMockMvcBuilder} that implements + * {@link MockMvcWebTestClient.RouterFunctionSpec}. + * + * @author Arjen Poutsma + * @since 6.2 + */ +class RouterFunctionMockMvcSpec extends AbstractMockMvcServerSpec + implements MockMvcWebTestClient.RouterFunctionSpec { + + private final RouterFunctionMockMvcBuilder mockMvcBuilder; + + + RouterFunctionMockMvcSpec(RouterFunction... routerFunctions) { + this.mockMvcBuilder = MockMvcBuilders.routerFunctions(routerFunctions); + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec messageConverters(HttpMessageConverter... messageConverters) { + this.mockMvcBuilder.setMessageConverters(messageConverters); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec interceptors(HandlerInterceptor... interceptors) { + mappedInterceptors(null, interceptors); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec mappedInterceptors(@Nullable String[] pathPatterns, HandlerInterceptor... interceptors) { + this.mockMvcBuilder.addMappedInterceptors(pathPatterns, interceptors); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec asyncRequestTimeout(long timeout) { + this.mockMvcBuilder.setAsyncRequestTimeout(timeout); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers) { + this.mockMvcBuilder.setHandlerExceptionResolvers(exceptionResolvers); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec viewResolvers(ViewResolver... resolvers) { + this.mockMvcBuilder.setViewResolvers(resolvers); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec singleView(View view) { + this.mockMvcBuilder.setSingleView(view); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec patternParser(PathPatternParser parser) { + this.mockMvcBuilder.setPatternParser(parser); + return this; + } + + @Override + protected ConfigurableMockMvcBuilder getMockMvcBuilder() { + return this.mockMvcBuilder; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/DelegatingWebConnection.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/DelegatingWebConnection.java index 186209784bfd..51b177a41680 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/DelegatingWebConnection.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/DelegatingWebConnection.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2024 the original author 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,9 @@ import java.util.Arrays; import java.util.List; -import com.gargoylesoftware.htmlunit.WebConnection; -import com.gargoylesoftware.htmlunit.WebRequest; -import com.gargoylesoftware.htmlunit.WebResponse; +import org.htmlunit.WebConnection; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; import org.springframework.util.Assert; diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HostRequestMatcher.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HostRequestMatcher.java index d8ce1166c9b4..33626560359c 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HostRequestMatcher.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HostRequestMatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +21,7 @@ import java.util.HashSet; import java.util.Set; -import com.gargoylesoftware.htmlunit.WebRequest; +import org.htmlunit.WebRequest; /** * A {@link WebRequestMatcher} that allows matching on the host and optionally diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HtmlUnitRequestBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HtmlUnitRequestBuilder.java index b0f89694eb3b..4c370d5727be 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HtmlUnitRequestBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/HtmlUnitRequestBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,15 +30,15 @@ import java.util.Set; import java.util.StringTokenizer; -import com.gargoylesoftware.htmlunit.FormEncodingType; -import com.gargoylesoftware.htmlunit.WebClient; -import com.gargoylesoftware.htmlunit.WebRequest; -import com.gargoylesoftware.htmlunit.util.KeyDataPair; -import com.gargoylesoftware.htmlunit.util.NameValuePair; import jakarta.servlet.ServletContext; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; +import org.htmlunit.FormEncodingType; +import org.htmlunit.WebClient; +import org.htmlunit.WebRequest; +import org.htmlunit.util.KeyDataPair; +import org.htmlunit.util.NameValuePair; import org.springframework.beans.Mergeable; import org.springframework.http.MediaType; @@ -301,8 +301,8 @@ private void cookies(MockHttpServletRequest request) { } } - Set managedCookies = this.webClient.getCookies(this.webRequest.getUrl()); - for (com.gargoylesoftware.htmlunit.util.Cookie cookie : managedCookies) { + Set managedCookies = this.webClient.getCookies(this.webRequest.getUrl()); + for (org.htmlunit.util.Cookie cookie : managedCookies) { processCookie(request, cookies, new Cookie(cookie.getName(), cookie.getValue())); } @@ -351,8 +351,8 @@ private void removeSessionCookie(MockHttpServletRequest request, String sessioni this.webClient.getCookieManager().removeCookie(createCookie(request, sessionid)); } - private com.gargoylesoftware.htmlunit.util.Cookie createCookie(MockHttpServletRequest request, String sessionid) { - return new com.gargoylesoftware.htmlunit.util.Cookie(request.getServerName(), "JSESSIONID", sessionid, + private org.htmlunit.util.Cookie createCookie(MockHttpServletRequest request, String sessionid) { + return new org.htmlunit.util.Cookie(request.getServerName(), "JSESSIONID", sessionid, request.getContextPath() + "/", null, request.isSecure(), true); } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebClientBuilder.java index 0232789e529c..cab9933e02cc 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebClientBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebClientBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2024 the original author 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.test.web.servlet.htmlunit; -import com.gargoylesoftware.htmlunit.WebClient; +import org.htmlunit.WebClient; import org.springframework.lang.Nullable; import org.springframework.test.web.servlet.MockMvc; diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnection.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnection.java index a3177d2fdcbc..165a9c0c5687 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnection.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnection.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,13 +21,13 @@ import java.util.HashMap; import java.util.Map; -import com.gargoylesoftware.htmlunit.CookieManager; -import com.gargoylesoftware.htmlunit.WebClient; -import com.gargoylesoftware.htmlunit.WebConnection; -import com.gargoylesoftware.htmlunit.WebRequest; -import com.gargoylesoftware.htmlunit.WebResponse; -import com.gargoylesoftware.htmlunit.util.Cookie; import org.apache.http.impl.cookie.BasicClientCookie; +import org.htmlunit.CookieManager; +import org.htmlunit.WebClient; +import org.htmlunit.WebConnection; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.htmlunit.util.Cookie; import org.springframework.lang.Nullable; import org.springframework.mock.web.MockHttpServletResponse; @@ -181,7 +181,7 @@ private void storeCookies(WebRequest webRequest, jakarta.servlet.http.Cookie[] c } @SuppressWarnings("removal") - private static com.gargoylesoftware.htmlunit.util.Cookie createCookie(jakarta.servlet.http.Cookie cookie) { + private static Cookie createCookie(jakarta.servlet.http.Cookie cookie) { Date expires = null; if (cookie.getMaxAge() > -1) { expires = new Date(System.currentTimeMillis() + cookie.getMaxAge() * 1000); @@ -195,7 +195,7 @@ private static com.gargoylesoftware.htmlunit.util.Cookie createCookie(jakarta.se if (cookie.isHttpOnly()) { result.setAttribute("httponly", "true"); } - return new com.gargoylesoftware.htmlunit.util.Cookie(result); + return new Cookie(result); } @Override diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnectionBuilderSupport.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnectionBuilderSupport.java index fa4418e52329..92addb6d76b6 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnectionBuilderSupport.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnectionBuilderSupport.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ import java.util.Collections; import java.util.List; -import com.gargoylesoftware.htmlunit.WebClient; -import com.gargoylesoftware.htmlunit.WebConnection; +import org.htmlunit.WebClient; +import org.htmlunit.WebConnection; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.htmlunit.DelegatingWebConnection.DelegateWebConnection; diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockWebResponseBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockWebResponseBuilder.java index ad78819f5803..8359880b8036 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockWebResponseBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/MockWebResponseBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2024 the original author 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,10 +21,10 @@ import java.util.Collection; import java.util.List; -import com.gargoylesoftware.htmlunit.WebRequest; -import com.gargoylesoftware.htmlunit.WebResponse; -import com.gargoylesoftware.htmlunit.WebResponseData; -import com.gargoylesoftware.htmlunit.util.NameValuePair; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.htmlunit.WebResponseData; +import org.htmlunit.util.NameValuePair; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockHttpServletResponse; diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/UrlRegexRequestMatcher.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/UrlRegexRequestMatcher.java index c7cc34df2310..189016ea7293 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/UrlRegexRequestMatcher.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/UrlRegexRequestMatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2024 the original author 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,7 @@ import java.util.regex.Pattern; -import com.gargoylesoftware.htmlunit.WebRequest; +import org.htmlunit.WebRequest; /** * A {@link WebRequestMatcher} that allows matching on diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/WebRequestMatcher.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/WebRequestMatcher.java index 4e6d39d28c12..11c8d2fdde19 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/WebRequestMatcher.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/WebRequestMatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2024 the original author 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.test.web.servlet.htmlunit; -import com.gargoylesoftware.htmlunit.WebRequest; +import org.htmlunit.WebRequest; /** * Strategy for matching on a {@link WebRequest}. diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/webdriver/MockMvcHtmlUnitDriverBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/webdriver/MockMvcHtmlUnitDriverBuilder.java index b7a665502a90..4092859f8fe6 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/webdriver/MockMvcHtmlUnitDriverBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/webdriver/MockMvcHtmlUnitDriverBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2024 the original author 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,8 +16,8 @@ package org.springframework.test.web.servlet.htmlunit.webdriver; -import com.gargoylesoftware.htmlunit.BrowserVersion; -import com.gargoylesoftware.htmlunit.WebClient; +import org.htmlunit.BrowserVersion; +import org.htmlunit.WebClient; import org.openqa.selenium.htmlunit.HtmlUnitDriver; import org.springframework.lang.Nullable; diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/webdriver/WebConnectionHtmlUnitDriver.java b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/webdriver/WebConnectionHtmlUnitDriver.java index 638648d234f5..5aed3f43000c 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/webdriver/WebConnectionHtmlUnitDriver.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/htmlunit/webdriver/WebConnectionHtmlUnitDriver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +16,9 @@ package org.springframework.test.web.servlet.htmlunit.webdriver; -import com.gargoylesoftware.htmlunit.BrowserVersion; -import com.gargoylesoftware.htmlunit.WebClient; -import com.gargoylesoftware.htmlunit.WebConnection; +import org.htmlunit.BrowserVersion; +import org.htmlunit.WebClient; +import org.htmlunit.WebConnection; import org.openqa.selenium.Capabilities; import org.openqa.selenium.htmlunit.HtmlUnitDriver; diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java new file mode 100644 index 000000000000..84e7cb47ef4e --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java @@ -0,0 +1,962 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.request; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpSession; + +import org.springframework.beans.Mergeable; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.context.support.WebApplicationContextUtils; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.FlashMap; +import org.springframework.web.servlet.FlashMapManager; +import org.springframework.web.servlet.support.SessionFlashMapManager; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; +import org.springframework.web.util.UrlPathHelper; + +/** + * Base builder for {@link MockHttpServletRequest} required as input to + * perform requests in {@link MockMvc}. + * + * @author Rossen Stoyanchev + * @author Juergen Hoeller + * @author Arjen Poutsma + * @author Sam Brannen + * @author Kamill Sokol + * @since 6.2 + * @param a self reference to the builder type + */ +public abstract class AbstractMockHttpServletRequestBuilder> + implements ConfigurableSmartRequestBuilder, Mergeable { + + private final HttpMethod method; + + @Nullable + private String uriTemplate; + + @Nullable + private URI uri; + + private String contextPath = ""; + + private String servletPath = ""; + + @Nullable + private String pathInfo = ""; + + @Nullable + private Boolean secure; + + @Nullable + private Principal principal; + + @Nullable + private MockHttpSession session; + + @Nullable + private String remoteAddress; + + @Nullable + private String characterEncoding; + + @Nullable + private byte[] content; + + @Nullable + private String contentType; + + private final MultiValueMap headers = new LinkedMultiValueMap<>(); + + private final MultiValueMap parameters = new LinkedMultiValueMap<>(); + + private final MultiValueMap queryParams = new LinkedMultiValueMap<>(); + + private final MultiValueMap formFields = new LinkedMultiValueMap<>(); + + private final List cookies = new ArrayList<>(); + + private final List locales = new ArrayList<>(); + + private final Map requestAttributes = new LinkedHashMap<>(); + + private final Map sessionAttributes = new LinkedHashMap<>(); + + private final Map flashAttributes = new LinkedHashMap<>(); + + private final List postProcessors = new ArrayList<>(); + + + /** + * Create a new instance using the specified {@link HttpMethod}. + * @param httpMethod the HTTP method (GET, POST, etc.) + */ + protected AbstractMockHttpServletRequestBuilder(HttpMethod httpMethod) { + Assert.notNull(httpMethod, "'httpMethod' is required"); + this.method = httpMethod; + } + + @SuppressWarnings("unchecked") + protected B self() { + return (B) this; + } + + /** + * Specify the URI using an absolute, fully constructed {@link java.net.URI}. + */ + public B uri(URI uri) { + return updateUri(uri, null); + } + + /** + * Specify the URI for the request using a URI template and URI variables. + */ + public B uri(String uriTemplate, Object... uriVariables) { + return updateUri(initUri(uriTemplate, uriVariables), uriTemplate); + } + + private B updateUri(URI uri, @Nullable String uriTemplate) { + this.uri = uri; + this.uriTemplate = uriTemplate; + return self(); + } + + private static URI initUri(String uri, Object[] vars) { + Assert.notNull(uri, "'uri' must not be null"); + Assert.isTrue(uri.isEmpty() || uri.startsWith("/") || uri.startsWith("http://") || uri.startsWith("https://"), + () -> "'uri' should start with a path or be a complete HTTP URI: " + uri); + String uriString = (uri.isEmpty() ? "/" : uri); + return UriComponentsBuilder.fromUriString(uriString).buildAndExpand(vars).encode().toUri(); + } + + /** + * Specify the portion of the requestURI that represents the context path. + * The context path, if specified, must match to the start of the request URI. + *

    In most cases, tests can be written by omitting the context path from + * the requestURI. This is because most applications don't actually depend + * on the name under which they're deployed. If specified here, the context + * path must start with a "/" and must not end with a "/". + * @see jakarta.servlet.http.HttpServletRequest#getContextPath() + */ + public B contextPath(String contextPath) { + if (StringUtils.hasText(contextPath)) { + Assert.isTrue(contextPath.startsWith("/"), "Context path must start with a '/'"); + Assert.isTrue(!contextPath.endsWith("/"), "Context path must not end with a '/'"); + } + this.contextPath = contextPath; + return self(); + } + + /** + * Specify the portion of the requestURI that represents the path to which + * the Servlet is mapped. This is typically a portion of the requestURI + * after the context path. + *

    In most cases, tests can be written by omitting the servlet path from + * the requestURI. This is because most applications don't actually depend + * on the prefix to which a servlet is mapped. For example if a Servlet is + * mapped to {@code "/main/*"}, tests can be written with the requestURI + * {@code "/accounts/1"} as opposed to {@code "/main/accounts/1"}. + * If specified here, the servletPath must start with a "/" and must not + * end with a "/". + * @see jakarta.servlet.http.HttpServletRequest#getServletPath() + */ + public B servletPath(String servletPath) { + if (StringUtils.hasText(servletPath)) { + Assert.isTrue(servletPath.startsWith("/"), "Servlet path must start with a '/'"); + Assert.isTrue(!servletPath.endsWith("/"), "Servlet path must not end with a '/'"); + } + this.servletPath = servletPath; + return self(); + } + + /** + * Specify the portion of the requestURI that represents the pathInfo. + *

    If left unspecified (recommended), the pathInfo will be automatically derived + * by removing the contextPath and the servletPath from the requestURI and using any + * remaining part. If specified here, the pathInfo must start with a "/". + *

    If specified, the pathInfo will be used as-is. + * @see jakarta.servlet.http.HttpServletRequest#getPathInfo() + */ + public B pathInfo(@Nullable String pathInfo) { + if (StringUtils.hasText(pathInfo)) { + Assert.isTrue(pathInfo.startsWith("/"), "Path info must start with a '/'"); + } + this.pathInfo = pathInfo; + return self(); + } + + /** + * Set the secure property of the {@link ServletRequest} indicating use of a + * secure channel, such as HTTPS. + * @param secure whether the request is using a secure channel + */ + public B secure(boolean secure){ + this.secure = secure; + return self(); + } + + /** + * Set the character encoding of the request. + * @param encoding the character encoding + * @since 5.3.10 + * @see StandardCharsets + * @see #characterEncoding(String) + */ + public B characterEncoding(Charset encoding) { + return characterEncoding(encoding.name()); + } + + /** + * Set the character encoding of the request. + * @param encoding the character encoding + */ + public B characterEncoding(String encoding) { + this.characterEncoding = encoding; + return self(); + } + + /** + * Set the request body. + *

    If content is provided and {@link #contentType(MediaType)} is set to + * {@code application/x-www-form-urlencoded}, the content will be parsed + * and used to populate the {@link #param(String, String...) request + * parameters} map. + * @param content the body content + */ + public B content(byte[] content) { + this.content = content; + return self(); + } + + /** + * Set the request body as a UTF-8 String. + *

    If content is provided and {@link #contentType(MediaType)} is set to + * {@code application/x-www-form-urlencoded}, the content will be parsed + * and used to populate the {@link #param(String, String...) request + * parameters} map. + * @param content the body content + */ + public B content(String content) { + this.content = content.getBytes(StandardCharsets.UTF_8); + return self(); + } + + /** + * Set the 'Content-Type' header of the request. + *

    If content is provided and {@code contentType} is set to + * {@code application/x-www-form-urlencoded}, the content will be parsed + * and used to populate the {@link #param(String, String...) request + * parameters} map. + * @param contentType the content type + */ + public B contentType(MediaType contentType) { + Assert.notNull(contentType, "'contentType' must not be null"); + this.contentType = contentType.toString(); + return self(); + } + + /** + * Set the 'Content-Type' header of the request as a raw String value, + * possibly not even well-formed (for testing purposes). + * @param contentType the content type + * @since 4.1.2 + */ + public B contentType(String contentType) { + Assert.notNull(contentType, "'contentType' must not be null"); + this.contentType = contentType; + return self(); + } + + /** + * Set the 'Accept' header to the given media type(s). + * @param mediaTypes one or more media types + */ + public B accept(MediaType... mediaTypes) { + Assert.notEmpty(mediaTypes, "'mediaTypes' must not be empty"); + this.headers.set("Accept", MediaType.toString(Arrays.asList(mediaTypes))); + return self(); + } + + /** + * Set the {@code Accept} header using raw String values, possibly not even + * well-formed (for testing purposes). + * @param mediaTypes one or more media types; internally joined as + * comma-separated String + */ + public B accept(String... mediaTypes) { + Assert.notEmpty(mediaTypes, "'mediaTypes' must not be empty"); + this.headers.set("Accept", String.join(", ", mediaTypes)); + return self(); + } + + /** + * Add a header to the request. Values are always added. + * @param name the header name + * @param values one or more header values + */ + public B header(String name, Object... values) { + addToMultiValueMap(this.headers, name, values); + return self(); + } + + /** + * Add all headers to the request. Values are always added. + * @param httpHeaders the headers and values to add + */ + public B headers(HttpHeaders httpHeaders) { + httpHeaders.forEach(this.headers::addAll); + return self(); + } + + /** + * Add a request parameter to {@link MockHttpServletRequest#getParameterMap()}. + *

    In the Servlet API, a request parameter may be parsed from the query + * string and/or from the body of an {@code application/x-www-form-urlencoded} + * request. This method simply adds to the request parameter map. You may + * also use add Servlet request parameters by specifying the query or form + * data through one of the following: + *

      + *
    • Supply a URL with a query to {@link MockMvcRequestBuilders}. + *
    • Add query params via {@link #queryParam} or {@link #queryParams}. + *
    • Provide {@link #content} with {@link #contentType} + * {@code application/x-www-form-urlencoded}. + *
    + * @param name the parameter name + * @param values one or more values + */ + public B param(String name, String... values) { + addToMultiValueMap(this.parameters, name, values); + return self(); + } + + /** + * Variant of {@link #param(String, String...)} with a {@link MultiValueMap}. + * @param params the parameters to add + * @since 4.2.4 + */ + public B params(MultiValueMap params) { + params.forEach((name, values) -> { + for (String value : values) { + this.parameters.add(name, value); + } + }); + return self(); + } + + /** + * Append to the query string and also add to the + * {@link #param(String, String...) request parameters} map. The parameter + * name and value are encoded when they are added to the query string. + * @param name the parameter name + * @param values one or more values + * @since 5.2.2 + */ + public B queryParam(String name, String... values) { + param(name, values); + this.queryParams.addAll(name, Arrays.asList(values)); + return self(); + } + + /** + * Append to the query string and also add to the + * {@link #params(MultiValueMap) request parameters} map. The parameter + * name and value are encoded when they are added to the query string. + * @param params the parameters to add + * @since 5.2.2 + */ + public B queryParams(MultiValueMap params) { + params(params); + this.queryParams.addAll(params); + return self(); + } + + /** + * Append the given value(s) to the given form field and also add them to the + * {@linkplain #param(String, String...) request parameters} map. + * @param name the field name + * @param values one or more values + * @since 6.1.7 + */ + public B formField(String name, String... values) { + param(name, values); + this.formFields.addAll(name, Arrays.asList(values)); + return self(); + } + + /** + * Variant of {@link #formField(String, String...)} with a {@link MultiValueMap}. + * @param formFields the form fields to add + * @since 6.1.7 + */ + public B formFields(MultiValueMap formFields) { + params(formFields); + this.formFields.addAll(formFields); + return self(); + } + + /** + * Add the given cookies to the request. Cookies are always added. + * @param cookies the cookies to add + */ + public B cookie(Cookie... cookies) { + Assert.notEmpty(cookies, "'cookies' must not be empty"); + this.cookies.addAll(Arrays.asList(cookies)); + return self(); + } + + /** + * Add the specified locales as preferred request locales. + * @param locales the locales to add + * @since 4.3.6 + * @see #locale(Locale) + */ + public B locale(Locale... locales) { + Assert.notEmpty(locales, "'locales' must not be empty"); + this.locales.addAll(Arrays.asList(locales)); + return self(); + } + + /** + * Set the locale of the request, overriding any previous locales. + * @param locale the locale, or {@code null} to reset it + * @see #locale(Locale...) + */ + public B locale(@Nullable Locale locale) { + this.locales.clear(); + if (locale != null) { + this.locales.add(locale); + } + return self(); + } + + /** + * Set a request attribute. + * @param name the attribute name + * @param value the attribute value + */ + public B requestAttr(String name, Object value) { + addToMap(this.requestAttributes, name, value); + return self(); + } + + /** + * Set a session attribute. + * @param name the session attribute name + * @param value the session attribute value + */ + public B sessionAttr(String name, Object value) { + addToMap(this.sessionAttributes, name, value); + return self(); + } + + /** + * Set session attributes. + * @param sessionAttributes the session attributes + */ + public B sessionAttrs(Map sessionAttributes) { + Assert.notEmpty(sessionAttributes, "'sessionAttributes' must not be empty"); + sessionAttributes.forEach(this::sessionAttr); + return self(); + } + + /** + * Set an "input" flash attribute. + * @param name the flash attribute name + * @param value the flash attribute value + */ + public B flashAttr(String name, Object value) { + addToMap(this.flashAttributes, name, value); + return self(); + } + + /** + * Set flash attributes. + * @param flashAttributes the flash attributes + */ + public B flashAttrs(Map flashAttributes) { + Assert.notEmpty(flashAttributes, "'flashAttributes' must not be empty"); + flashAttributes.forEach(this::flashAttr); + return self(); + } + + /** + * Set the HTTP session to use, possibly re-used across requests. + *

    Individual attributes provided via {@link #sessionAttr(String, Object)} + * override the content of the session provided here. + * @param session the HTTP session + */ + public B session(MockHttpSession session) { + Assert.notNull(session, "'session' must not be null"); + this.session = session; + return self(); + } + + /** + * Set the principal of the request. + * @param principal the principal + */ + public B principal(Principal principal) { + Assert.notNull(principal, "'principal' must not be null"); + this.principal = principal; + return self(); + } + + /** + * Set the remote address of the request. + * @param remoteAddress the remote address (IP) + * @since 6.0.10 + */ + public B remoteAddress(String remoteAddress) { + Assert.hasText(remoteAddress, "'remoteAddress' must not be null or blank"); + this.remoteAddress = remoteAddress; + return self(); + } + + /** + * An extension point for further initialization of {@link MockHttpServletRequest} + * in ways not built directly into the {@code MockHttpServletRequestBuilder}. + * Implementation of this interface can have builder-style methods themselves + * and be made accessible through static factory methods. + * @param postProcessor a post-processor to add + */ + @Override + public B with(RequestPostProcessor postProcessor) { + Assert.notNull(postProcessor, "postProcessor is required"); + this.postProcessors.add(postProcessor); + return self(); + } + + + /** + * {@inheritDoc} + * @return always returns {@code true}. + */ + @Override + public boolean isMergeEnabled() { + return true; + } + + /** + * Merges the properties of the "parent" RequestBuilder accepting values + * only if not already set in "this" instance. + * @param parent the parent {@code RequestBuilder} to inherit properties from + * @return the result of the merge + */ + @Override + public Object merge(@Nullable Object parent) { + if (parent == null) { + return this; + } + if (!(parent instanceof AbstractMockHttpServletRequestBuilder parentBuilder)) { + throw new IllegalArgumentException("Cannot merge with [" + parent.getClass().getName() + "]"); + } + if (this.uri == null) { + this.uri = parentBuilder.uri; + this.uriTemplate = parentBuilder.uriTemplate; + } + if (!StringUtils.hasText(this.contextPath)) { + this.contextPath = parentBuilder.contextPath; + } + if (!StringUtils.hasText(this.servletPath)) { + this.servletPath = parentBuilder.servletPath; + } + if ("".equals(this.pathInfo)) { + this.pathInfo = parentBuilder.pathInfo; + } + + if (this.secure == null) { + this.secure = parentBuilder.secure; + } + if (this.principal == null) { + this.principal = parentBuilder.principal; + } + if (this.session == null) { + this.session = parentBuilder.session; + } + if (this.remoteAddress == null) { + this.remoteAddress = parentBuilder.remoteAddress; + } + + if (this.characterEncoding == null) { + this.characterEncoding = parentBuilder.characterEncoding; + } + if (this.content == null) { + this.content = parentBuilder.content; + } + if (this.contentType == null) { + this.contentType = parentBuilder.contentType; + } + + for (Map.Entry> entry : parentBuilder.headers.entrySet()) { + String headerName = entry.getKey(); + if (!this.headers.containsKey(headerName)) { + this.headers.put(headerName, entry.getValue()); + } + } + for (Map.Entry> entry : parentBuilder.parameters.entrySet()) { + String paramName = entry.getKey(); + if (!this.parameters.containsKey(paramName)) { + this.parameters.put(paramName, entry.getValue()); + } + } + for (Map.Entry> entry : parentBuilder.queryParams.entrySet()) { + String paramName = entry.getKey(); + if (!this.queryParams.containsKey(paramName)) { + this.queryParams.put(paramName, entry.getValue()); + } + } + for (Map.Entry> entry : parentBuilder.formFields.entrySet()) { + String paramName = entry.getKey(); + if (!this.formFields.containsKey(paramName)) { + this.formFields.put(paramName, entry.getValue()); + } + } + for (Cookie cookie : parentBuilder.cookies) { + if (!containsCookie(cookie)) { + this.cookies.add(cookie); + } + } + for (Locale locale : parentBuilder.locales) { + if (!this.locales.contains(locale)) { + this.locales.add(locale); + } + } + + for (Map.Entry entry : parentBuilder.requestAttributes.entrySet()) { + String attributeName = entry.getKey(); + if (!this.requestAttributes.containsKey(attributeName)) { + this.requestAttributes.put(attributeName, entry.getValue()); + } + } + for (Map.Entry entry : parentBuilder.sessionAttributes.entrySet()) { + String attributeName = entry.getKey(); + if (!this.sessionAttributes.containsKey(attributeName)) { + this.sessionAttributes.put(attributeName, entry.getValue()); + } + } + for (Map.Entry entry : parentBuilder.flashAttributes.entrySet()) { + String attributeName = entry.getKey(); + if (!this.flashAttributes.containsKey(attributeName)) { + this.flashAttributes.put(attributeName, entry.getValue()); + } + } + + this.postProcessors.addAll(0, parentBuilder.postProcessors); + + return this; + } + + private boolean containsCookie(Cookie cookie) { + for (Cookie cookieToCheck : this.cookies) { + if (ObjectUtils.nullSafeEquals(cookieToCheck.getName(), cookie.getName())) { + return true; + } + } + return false; + } + + /** + * Build a {@link MockHttpServletRequest}. + */ + @Override + public final MockHttpServletRequest buildRequest(ServletContext servletContext) { + Assert.notNull(this.uri, "'uri' is required"); + MockHttpServletRequest request = createServletRequest(servletContext); + + request.setAsyncSupported(true); + request.setMethod(this.method.name()); + + request.setUriTemplate(this.uriTemplate); + + String requestUri = this.uri.getRawPath(); + request.setRequestURI(requestUri); + + if (this.uri.getScheme() != null) { + request.setScheme(this.uri.getScheme()); + } + if (this.uri.getHost() != null) { + request.setServerName(this.uri.getHost()); + } + if (this.uri.getPort() != -1) { + request.setServerPort(this.uri.getPort()); + } + + updatePathRequestProperties(request, requestUri); + + if (this.secure != null) { + request.setSecure(this.secure); + } + if (this.principal != null) { + request.setUserPrincipal(this.principal); + } + if (this.remoteAddress != null) { + request.setRemoteAddr(this.remoteAddress); + } + if (this.session != null) { + request.setSession(this.session); + } + + request.setCharacterEncoding(this.characterEncoding); + request.setContent(this.content); + request.setContentType(this.contentType); + + this.headers.forEach((name, values) -> { + for (Object value : values) { + request.addHeader(name, value); + } + }); + + if (!ObjectUtils.isEmpty(this.content) && + !this.headers.containsKey(HttpHeaders.CONTENT_LENGTH) && + !this.headers.containsKey(HttpHeaders.TRANSFER_ENCODING)) { + + request.addHeader(HttpHeaders.CONTENT_LENGTH, this.content.length); + } + + String query = this.uri.getRawQuery(); + if (!this.queryParams.isEmpty()) { + String str = UriComponentsBuilder.newInstance().queryParams(this.queryParams).build().encode().getQuery(); + query = StringUtils.hasLength(query) ? (query + "&" + str) : str; + } + if (query != null) { + request.setQueryString(query); + } + addRequestParams(request, UriComponentsBuilder.fromUri(this.uri).build().getQueryParams()); + + this.parameters.forEach((name, values) -> { + for (String value : values) { + request.addParameter(name, value); + } + }); + + if (!this.formFields.isEmpty()) { + if (this.content != null && this.content.length > 0) { + throw new IllegalStateException("Could not write form data with an existing body"); + } + Charset charset = (this.characterEncoding != null ? + Charset.forName(this.characterEncoding) : StandardCharsets.UTF_8); + MediaType mediaType = (request.getContentType() != null ? + MediaType.parseMediaType(request.getContentType()) : + new MediaType(MediaType.APPLICATION_FORM_URLENCODED, charset)); + if (!mediaType.isCompatibleWith(MediaType.APPLICATION_FORM_URLENCODED)) { + throw new IllegalStateException("Invalid content type: '" + mediaType + + "' is not compatible with '" + MediaType.APPLICATION_FORM_URLENCODED + "'"); + } + request.setContent(writeFormData(mediaType, charset)); + if (request.getContentType() == null) { + request.setContentType(mediaType.toString()); + } + } + if (this.content != null && this.content.length > 0) { + String requestContentType = request.getContentType(); + if (requestContentType != null) { + try { + MediaType mediaType = MediaType.parseMediaType(requestContentType); + if (MediaType.APPLICATION_FORM_URLENCODED.includes(mediaType)) { + addRequestParams(request, parseFormData(mediaType)); + } + } + catch (Exception ex) { + // Must be invalid, ignore + } + } + } + + if (!ObjectUtils.isEmpty(this.cookies)) { + request.setCookies(this.cookies.toArray(new Cookie[0])); + } + if (!ObjectUtils.isEmpty(this.locales)) { + request.setPreferredLocales(this.locales); + } + + this.requestAttributes.forEach(request::setAttribute); + this.sessionAttributes.forEach((name, attribute) -> { + HttpSession session = request.getSession(); + Assert.state(session != null, "No HttpSession"); + session.setAttribute(name, attribute); + }); + + FlashMap flashMap = new FlashMap(); + flashMap.putAll(this.flashAttributes); + FlashMapManager flashMapManager = getFlashMapManager(request); + flashMapManager.saveOutputFlashMap(flashMap, request, new MockHttpServletResponse()); + + return request; + } + + /** + * Create a new {@link MockHttpServletRequest} based on the supplied + * {@code ServletContext}. + *

    Can be overridden in subclasses. + */ + protected MockHttpServletRequest createServletRequest(ServletContext servletContext) { + return new MockHttpServletRequest(servletContext); + } + + /** + * Update the contextPath, servletPath, and pathInfo of the request. + */ + private void updatePathRequestProperties(MockHttpServletRequest request, String requestUri) { + if (!requestUri.startsWith(this.contextPath)) { + throw new IllegalArgumentException( + "Request URI [" + requestUri + "] does not start with context path [" + this.contextPath + "]"); + } + request.setContextPath(this.contextPath); + request.setServletPath(this.servletPath); + + if ("".equals(this.pathInfo)) { + if (!requestUri.startsWith(this.contextPath + this.servletPath)) { + throw new IllegalArgumentException( + "Invalid servlet path [" + this.servletPath + "] for request URI [" + requestUri + "]"); + } + String extraPath = requestUri.substring(this.contextPath.length() + this.servletPath.length()); + this.pathInfo = (StringUtils.hasText(extraPath) ? + UrlPathHelper.defaultInstance.decodeRequestString(request, extraPath) : null); + } + request.setPathInfo(this.pathInfo); + } + + private void addRequestParams(MockHttpServletRequest request, MultiValueMap map) { + map.forEach((key, values) -> values.forEach(value -> { + value = (value != null ? UriUtils.decode(value, StandardCharsets.UTF_8) : null); + request.addParameter(UriUtils.decode(key, StandardCharsets.UTF_8), value); + })); + } + + private byte[] writeFormData(MediaType mediaType, Charset charset) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + HttpOutputMessage message = new HttpOutputMessage() { + @Override + public OutputStream getBody() { + return out; + } + + @Override + public HttpHeaders getHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(mediaType); + return headers; + } + }; + try { + FormHttpMessageConverter messageConverter = new FormHttpMessageConverter(); + messageConverter.setCharset(charset); + messageConverter.write(this.formFields, mediaType, message); + return out.toByteArray(); + } + catch (IOException ex) { + throw new IllegalStateException("Failed to write form data to request body", ex); + } + } + + @SuppressWarnings("unchecked") + private MultiValueMap parseFormData(MediaType mediaType) { + HttpInputMessage message = new HttpInputMessage() { + @Override + public InputStream getBody() { + byte[] bodyContent = AbstractMockHttpServletRequestBuilder.this.content; + return (bodyContent != null ? new ByteArrayInputStream(bodyContent) : InputStream.nullInputStream()); + } + @Override + public HttpHeaders getHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(mediaType); + return headers; + } + }; + + try { + return new FormHttpMessageConverter().read(null, message); + } + catch (IOException ex) { + throw new IllegalStateException("Failed to parse form data in request body", ex); + } + } + + private FlashMapManager getFlashMapManager(MockHttpServletRequest request) { + FlashMapManager flashMapManager = null; + try { + ServletContext servletContext = request.getServletContext(); + WebApplicationContext wac = WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext); + flashMapManager = wac.getBean(DispatcherServlet.FLASH_MAP_MANAGER_BEAN_NAME, FlashMapManager.class); + } + catch (IllegalStateException | NoSuchBeanDefinitionException ex) { + // ignore + } + return (flashMapManager != null ? flashMapManager : new SessionFlashMapManager()); + } + + @Override + public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { + for (RequestPostProcessor postProcessor : this.postProcessors) { + request = postProcessor.postProcessRequest(request); + } + return request; + } + + + private static void addToMap(Map map, String name, Object value) { + Assert.hasLength(name, "'name' must not be empty"); + Assert.notNull(value, "'value' must not be null"); + map.put(name, value); + } + + private static void addToMultiValueMap(MultiValueMap map, String name, T[] values) { + Assert.hasLength(name, "'name' must not be empty"); + Assert.notEmpty(values, "'values' must not be empty"); + for (T value : values) { + map.add(name, value); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockMultipartHttpServletRequestBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockMultipartHttpServletRequestBuilder.java new file mode 100644 index 000000000000..9b9de5adb5cc --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockMultipartHttpServletRequestBuilder.java @@ -0,0 +1,160 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.request; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import jakarta.servlet.ServletContext; +import jakarta.servlet.http.Part; + +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.mock.web.MockMultipartHttpServletRequest; +import org.springframework.util.Assert; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Base builder for {@link MockMultipartHttpServletRequest}. + * + * @author Rossen Stoyanchev + * @author Arjen Poutsma + * @author Stephane Nicoll + * @since 6.2 + * @param a self reference to the builder type + */ +public abstract class AbstractMockMultipartHttpServletRequestBuilder> + extends AbstractMockHttpServletRequestBuilder { + + private final List files = new ArrayList<>(); + + private final MultiValueMap parts = new LinkedMultiValueMap<>(); + + + protected AbstractMockMultipartHttpServletRequestBuilder(HttpMethod httpMethod) { + super(httpMethod); + super.contentType(MediaType.MULTIPART_FORM_DATA); + } + + /** + * Add a new {@link MockMultipartFile} with the given content. + * @param name the name of the file + * @param content the content of the file + */ + public B file(String name, byte[] content) { + this.files.add(new MockMultipartFile(name, content)); + return self(); + } + + /** + * Add the given {@link MockMultipartFile}. + * @param file the multipart file + */ + public B file(MockMultipartFile file) { + this.files.add(file); + return self(); + } + + /** + * Add {@link Part} components to the request. + * @param parts one or more parts to add + * @since 5.0 + */ + public B part(Part... parts) { + Assert.notEmpty(parts, "'parts' must not be empty"); + for (Part part : parts) { + this.parts.add(part.getName(), part); + } + return self(); + } + + @Override + public Object merge(@Nullable Object parent) { + if (parent == null) { + return this; + } + if (parent instanceof AbstractMockHttpServletRequestBuilder) { + super.merge(parent); + if (parent instanceof AbstractMockMultipartHttpServletRequestBuilder parentBuilder) { + this.files.addAll(parentBuilder.files); + parentBuilder.parts.keySet().forEach(name -> + this.parts.putIfAbsent(name, parentBuilder.parts.get(name))); + } + } + else { + throw new IllegalArgumentException("Cannot merge with [" + parent.getClass().getName() + "]"); + } + return this; + } + + /** + * Create a new {@link MockMultipartHttpServletRequest} based on the + * supplied {@code ServletContext} and the {@code MockMultipartFiles} + * added to this builder. + */ + @Override + protected final MockHttpServletRequest createServletRequest(ServletContext servletContext) { + MockMultipartHttpServletRequest request = new MockMultipartHttpServletRequest(servletContext); + Charset defaultCharset = (request.getCharacterEncoding() != null ? + Charset.forName(request.getCharacterEncoding()) : StandardCharsets.UTF_8); + + this.files.forEach(request::addFile); + this.parts.values().stream().flatMap(Collection::stream).forEach(part -> { + request.addPart(part); + try { + String name = part.getName(); + String filename = part.getSubmittedFileName(); + InputStream is = part.getInputStream(); + if (filename != null) { + request.addFile(new MockMultipartFile(name, filename, part.getContentType(), is)); + } + else { + InputStreamReader reader = new InputStreamReader(is, getCharsetOrDefault(part, defaultCharset)); + String value = FileCopyUtils.copyToString(reader); + request.addParameter(part.getName(), value); + } + } + catch (IOException ex) { + throw new IllegalStateException("Failed to read content for part " + part.getName(), ex); + } + }); + + return request; + } + + private Charset getCharsetOrDefault(Part part, Charset defaultCharset) { + if (part.getContentType() != null) { + MediaType mediaType = MediaType.parseMediaType(part.getContentType()); + if (mediaType.getCharset() != null) { + return mediaType.getCharset(); + } + } + return defaultCharset; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilder.java index 433d78e86c27..4ec50f72c0c3 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilder.java @@ -16,54 +16,22 @@ package org.springframework.test.web.servlet.request; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.net.URI; import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; import java.security.Principal; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; import java.util.Locale; import java.util.Map; -import jakarta.servlet.ServletContext; -import jakarta.servlet.ServletRequest; import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpSession; -import org.springframework.beans.Mergeable; -import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpMethod; -import org.springframework.http.HttpOutputMessage; import org.springframework.http.MediaType; -import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.lang.Nullable; import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockHttpSession; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.util.Assert; -import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; -import org.springframework.util.ObjectUtils; -import org.springframework.util.StringUtils; -import org.springframework.web.context.WebApplicationContext; -import org.springframework.web.context.support.WebApplicationContextUtils; -import org.springframework.web.servlet.DispatcherServlet; -import org.springframework.web.servlet.FlashMap; -import org.springframework.web.servlet.FlashMapManager; -import org.springframework.web.servlet.support.SessionFlashMapManager; -import org.springframework.web.util.UriComponentsBuilder; -import org.springframework.web.util.UriUtils; -import org.springframework.web.util.UrlPathHelper; /** * Default builder for {@link MockHttpServletRequest} required as input to @@ -84,60 +52,7 @@ * @since 3.2 */ public class MockHttpServletRequestBuilder - implements ConfigurableSmartRequestBuilder, Mergeable { - - private final String method; - - private final URI url; - - private String contextPath = ""; - - private String servletPath = ""; - - @Nullable - private String pathInfo = ""; - - @Nullable - private Boolean secure; - - @Nullable - private Principal principal; - - @Nullable - private MockHttpSession session; - - @Nullable - private String remoteAddress; - - @Nullable - private String characterEncoding; - - @Nullable - private byte[] content; - - @Nullable - private String contentType; - - private final MultiValueMap headers = new LinkedMultiValueMap<>(); - - private final MultiValueMap parameters = new LinkedMultiValueMap<>(); - - private final MultiValueMap queryParams = new LinkedMultiValueMap<>(); - - private final MultiValueMap formFields = new LinkedMultiValueMap<>(); - - private final List cookies = new ArrayList<>(); - - private final List locales = new ArrayList<>(); - - private final Map requestAttributes = new LinkedHashMap<>(); - - private final Map sessionAttributes = new LinkedHashMap<>(); - - private final Map flashAttributes = new LinkedHashMap<>(); - - private final List postProcessors = new ArrayList<>(); - + extends AbstractMockHttpServletRequestBuilder { /** * Package private constructor. To get an instance, use static factory @@ -145,816 +60,183 @@ public class MockHttpServletRequestBuilder *

    Although this class cannot be extended, additional ways to initialize * the {@code MockHttpServletRequest} can be plugged in via * {@link #with(RequestPostProcessor)}. - * @param httpMethod the HTTP method (GET, POST, etc) - * @param url a URL template; the resulting URL will be encoded - * @param vars zero or more URI variables + * @param httpMethod the HTTP method (GET, POST, etc.) */ - MockHttpServletRequestBuilder(HttpMethod httpMethod, String url, Object... vars) { - this(httpMethod.name(), initUri(url, vars)); + MockHttpServletRequestBuilder(HttpMethod httpMethod) { + super(httpMethod); } - private static URI initUri(String url, Object[] vars) { - Assert.notNull(url, "'url' must not be null"); - Assert.isTrue(url.isEmpty() || url.startsWith("/") || url.startsWith("http://") || url.startsWith("https://"), - () -> "'url' should start with a path or be a complete HTTP URL: " + url); - String uriString = (url.isEmpty() ? "/" : url); - return UriComponentsBuilder.fromUriString(uriString).buildAndExpand(vars).encode().toUri(); - } - /** - * Alternative to {@link #MockHttpServletRequestBuilder(HttpMethod, String, Object...)} - * with a pre-built URI. - * @param httpMethod the HTTP method (GET, POST, etc) - * @param url the URL - * @since 4.0.3 - */ - MockHttpServletRequestBuilder(HttpMethod httpMethod, URI url) { - this(httpMethod.name(), url); - } + // Override to keep binary compatibility. - /** - * Alternative constructor for custom HTTP methods. - * @param httpMethod the HTTP method (GET, POST, etc) - * @param url the URL - * @since 4.3 - */ - MockHttpServletRequestBuilder(String httpMethod, URI url) { - Assert.notNull(httpMethod, "'httpMethod' is required"); - Assert.notNull(url, "'url' is required"); - this.method = httpMethod; - this.url = url; + @Override + public MockHttpServletRequestBuilder uri(URI uri) { + return super.uri(uri); } + @Override + public MockHttpServletRequestBuilder uri(String uriTemplate, Object... uriVariables) { + return super.uri(uriTemplate, uriVariables); + } - /** - * Specify the portion of the requestURI that represents the context path. - * The context path, if specified, must match to the start of the request URI. - *

    In most cases, tests can be written by omitting the context path from - * the requestURI. This is because most applications don't actually depend - * on the name under which they're deployed. If specified here, the context - * path must start with a "/" and must not end with a "/". - * @see jakarta.servlet.http.HttpServletRequest#getContextPath() - */ + @Override public MockHttpServletRequestBuilder contextPath(String contextPath) { - if (StringUtils.hasText(contextPath)) { - Assert.isTrue(contextPath.startsWith("/"), "Context path must start with a '/'"); - Assert.isTrue(!contextPath.endsWith("/"), "Context path must not end with a '/'"); - } - this.contextPath = contextPath; - return this; + return super.contextPath(contextPath); } - /** - * Specify the portion of the requestURI that represents the path to which - * the Servlet is mapped. This is typically a portion of the requestURI - * after the context path. - *

    In most cases, tests can be written by omitting the servlet path from - * the requestURI. This is because most applications don't actually depend - * on the prefix to which a servlet is mapped. For example if a Servlet is - * mapped to {@code "/main/*"}, tests can be written with the requestURI - * {@code "/accounts/1"} as opposed to {@code "/main/accounts/1"}. - * If specified here, the servletPath must start with a "/" and must not - * end with a "/". - * @see jakarta.servlet.http.HttpServletRequest#getServletPath() - */ + @Override public MockHttpServletRequestBuilder servletPath(String servletPath) { - if (StringUtils.hasText(servletPath)) { - Assert.isTrue(servletPath.startsWith("/"), "Servlet path must start with a '/'"); - Assert.isTrue(!servletPath.endsWith("/"), "Servlet path must not end with a '/'"); - } - this.servletPath = servletPath; - return this; + return super.servletPath(servletPath); } - /** - * Specify the portion of the requestURI that represents the pathInfo. - *

    If left unspecified (recommended), the pathInfo will be automatically derived - * by removing the contextPath and the servletPath from the requestURI and using any - * remaining part. If specified here, the pathInfo must start with a "/". - *

    If specified, the pathInfo will be used as-is. - * @see jakarta.servlet.http.HttpServletRequest#getPathInfo() - */ + @Override public MockHttpServletRequestBuilder pathInfo(@Nullable String pathInfo) { - if (StringUtils.hasText(pathInfo)) { - Assert.isTrue(pathInfo.startsWith("/"), "Path info must start with a '/'"); - } - this.pathInfo = pathInfo; - return this; + return super.pathInfo(pathInfo); } - /** - * Set the secure property of the {@link ServletRequest} indicating use of a - * secure channel, such as HTTPS. - * @param secure whether the request is using a secure channel - */ - public MockHttpServletRequestBuilder secure(boolean secure){ - this.secure = secure; - return this; + @Override + public MockHttpServletRequestBuilder secure(boolean secure) { + return super.secure(secure); } - /** - * Set the character encoding of the request. - * @param encoding the character encoding - * @since 5.3.10 - * @see StandardCharsets - * @see #characterEncoding(String) - */ + @Override public MockHttpServletRequestBuilder characterEncoding(Charset encoding) { - return this.characterEncoding(encoding.name()); + return super.characterEncoding(encoding); } - /** - * Set the character encoding of the request. - * @param encoding the character encoding - */ + @Override public MockHttpServletRequestBuilder characterEncoding(String encoding) { - this.characterEncoding = encoding; - return this; + return super.characterEncoding(encoding); } - /** - * Set the request body. - *

    If content is provided and {@link #contentType(MediaType)} is set to - * {@code application/x-www-form-urlencoded}, the content will be parsed - * and used to populate the {@link #param(String, String...) request - * parameters} map. - * @param content the body content - */ + @Override public MockHttpServletRequestBuilder content(byte[] content) { - this.content = content; - return this; + return super.content(content); } - /** - * Set the request body as a UTF-8 String. - *

    If content is provided and {@link #contentType(MediaType)} is set to - * {@code application/x-www-form-urlencoded}, the content will be parsed - * and used to populate the {@link #param(String, String...) request - * parameters} map. - * @param content the body content - */ + @Override public MockHttpServletRequestBuilder content(String content) { - this.content = content.getBytes(StandardCharsets.UTF_8); - return this; + return super.content(content); } - /** - * Set the 'Content-Type' header of the request. - *

    If content is provided and {@code contentType} is set to - * {@code application/x-www-form-urlencoded}, the content will be parsed - * and used to populate the {@link #param(String, String...) request - * parameters} map. - * @param contentType the content type - */ + @Override public MockHttpServletRequestBuilder contentType(MediaType contentType) { - Assert.notNull(contentType, "'contentType' must not be null"); - this.contentType = contentType.toString(); - return this; + return super.contentType(contentType); } - /** - * Set the 'Content-Type' header of the request as a raw String value, - * possibly not even well-formed (for testing purposes). - * @param contentType the content type - * @since 4.1.2 - */ + @Override public MockHttpServletRequestBuilder contentType(String contentType) { - Assert.notNull(contentType, "'contentType' must not be null"); - this.contentType = contentType; - return this; + return super.contentType(contentType); } - /** - * Set the 'Accept' header to the given media type(s). - * @param mediaTypes one or more media types - */ + @Override public MockHttpServletRequestBuilder accept(MediaType... mediaTypes) { - Assert.notEmpty(mediaTypes, "'mediaTypes' must not be empty"); - this.headers.set("Accept", MediaType.toString(Arrays.asList(mediaTypes))); - return this; + return super.accept(mediaTypes); } - /** - * Set the {@code Accept} header using raw String values, possibly not even - * well-formed (for testing purposes). - * @param mediaTypes one or more media types; internally joined as - * comma-separated String - */ + @Override public MockHttpServletRequestBuilder accept(String... mediaTypes) { - Assert.notEmpty(mediaTypes, "'mediaTypes' must not be empty"); - this.headers.set("Accept", String.join(", ", mediaTypes)); - return this; + return super.accept(mediaTypes); } - /** - * Add a header to the request. Values are always added. - * @param name the header name - * @param values one or more header values - */ + @Override public MockHttpServletRequestBuilder header(String name, Object... values) { - addToMultiValueMap(this.headers, name, values); - return this; + return super.header(name, values); } - /** - * Add all headers to the request. Values are always added. - * @param httpHeaders the headers and values to add - */ + @Override public MockHttpServletRequestBuilder headers(HttpHeaders httpHeaders) { - httpHeaders.forEach(this.headers::addAll); - return this; + return super.headers(httpHeaders); } - /** - * Add a request parameter to {@link MockHttpServletRequest#getParameterMap()}. - *

    In the Servlet API, a request parameter may be parsed from the query - * string and/or from the body of an {@code application/x-www-form-urlencoded} - * request. This method simply adds to the request parameter map. You may - * also use add Servlet request parameters by specifying the query or form - * data through one of the following: - *

      - *
    • Supply a URL with a query to {@link MockMvcRequestBuilders}. - *
    • Add query params via {@link #queryParam} or {@link #queryParams}. - *
    • Provide {@link #content} with {@link #contentType} - * {@code application/x-www-form-urlencoded}. - *
    - * @param name the parameter name - * @param values one or more values - */ + @Override public MockHttpServletRequestBuilder param(String name, String... values) { - addToMultiValueMap(this.parameters, name, values); - return this; + return super.param(name, values); } - /** - * Variant of {@link #param(String, String...)} with a {@link MultiValueMap}. - * @param params the parameters to add - * @since 4.2.4 - */ + @Override public MockHttpServletRequestBuilder params(MultiValueMap params) { - params.forEach((name, values) -> { - for (String value : values) { - this.parameters.add(name, value); - } - }); - return this; + return super.params(params); } - /** - * Append to the query string and also add to the - * {@link #param(String, String...) request parameters} map. The parameter - * name and value are encoded when they are added to the query string. - * @param name the parameter name - * @param values one or more values - * @since 5.2.2 - */ + @Override public MockHttpServletRequestBuilder queryParam(String name, String... values) { - param(name, values); - this.queryParams.addAll(name, Arrays.asList(values)); - return this; + return super.queryParam(name, values); } - /** - * Append to the query string and also add to the - * {@link #params(MultiValueMap) request parameters} map. The parameter - * name and value are encoded when they are added to the query string. - * @param params the parameters to add - * @since 5.2.2 - */ + @Override public MockHttpServletRequestBuilder queryParams(MultiValueMap params) { - params(params); - this.queryParams.addAll(params); - return this; + return super.queryParams(params); } - /** - * Append the given value(s) to the given form field and also add them to the - * {@linkplain #param(String, String...) request parameters} map. - * @param name the field name - * @param values one or more values - * @since 6.1.7 - */ + @Override public MockHttpServletRequestBuilder formField(String name, String... values) { - param(name, values); - this.formFields.addAll(name, Arrays.asList(values)); - return this; + return super.formField(name, values); } - /** - * Variant of {@link #formField(String, String...)} with a {@link MultiValueMap}. - * @param formFields the form fields to add - * @since 6.1.7 - */ + @Override public MockHttpServletRequestBuilder formFields(MultiValueMap formFields) { - params(formFields); - this.formFields.addAll(formFields); - return this; + return super.formFields(formFields); } - /** - * Add the given cookies to the request. Cookies are always added. - * @param cookies the cookies to add - */ + @Override public MockHttpServletRequestBuilder cookie(Cookie... cookies) { - Assert.notEmpty(cookies, "'cookies' must not be empty"); - this.cookies.addAll(Arrays.asList(cookies)); - return this; + return super.cookie(cookies); } - /** - * Add the specified locales as preferred request locales. - * @param locales the locales to add - * @since 4.3.6 - * @see #locale(Locale) - */ + @Override public MockHttpServletRequestBuilder locale(Locale... locales) { - Assert.notEmpty(locales, "'locales' must not be empty"); - this.locales.addAll(Arrays.asList(locales)); - return this; + return super.locale(locales); } - /** - * Set the locale of the request, overriding any previous locales. - * @param locale the locale, or {@code null} to reset it - * @see #locale(Locale...) - */ + @Override public MockHttpServletRequestBuilder locale(@Nullable Locale locale) { - this.locales.clear(); - if (locale != null) { - this.locales.add(locale); - } - return this; + return super.locale(locale); } - /** - * Set a request attribute. - * @param name the attribute name - * @param value the attribute value - */ + @Override public MockHttpServletRequestBuilder requestAttr(String name, Object value) { - addToMap(this.requestAttributes, name, value); - return this; + return super.requestAttr(name, value); } - /** - * Set a session attribute. - * @param name the session attribute name - * @param value the session attribute value - */ + @Override public MockHttpServletRequestBuilder sessionAttr(String name, Object value) { - addToMap(this.sessionAttributes, name, value); - return this; + return super.sessionAttr(name, value); } - /** - * Set session attributes. - * @param sessionAttributes the session attributes - */ + @Override public MockHttpServletRequestBuilder sessionAttrs(Map sessionAttributes) { - Assert.notEmpty(sessionAttributes, "'sessionAttributes' must not be empty"); - sessionAttributes.forEach(this::sessionAttr); - return this; + return super.sessionAttrs(sessionAttributes); } - /** - * Set an "input" flash attribute. - * @param name the flash attribute name - * @param value the flash attribute value - */ + @Override public MockHttpServletRequestBuilder flashAttr(String name, Object value) { - addToMap(this.flashAttributes, name, value); - return this; + return super.flashAttr(name, value); } - /** - * Set flash attributes. - * @param flashAttributes the flash attributes - */ + @Override public MockHttpServletRequestBuilder flashAttrs(Map flashAttributes) { - Assert.notEmpty(flashAttributes, "'flashAttributes' must not be empty"); - flashAttributes.forEach(this::flashAttr); - return this; + return super.flashAttrs(flashAttributes); } - /** - * Set the HTTP session to use, possibly re-used across requests. - *

    Individual attributes provided via {@link #sessionAttr(String, Object)} - * override the content of the session provided here. - * @param session the HTTP session - */ + @Override public MockHttpServletRequestBuilder session(MockHttpSession session) { - Assert.notNull(session, "'session' must not be null"); - this.session = session; - return this; + return super.session(session); } - /** - * Set the principal of the request. - * @param principal the principal - */ + @Override public MockHttpServletRequestBuilder principal(Principal principal) { - Assert.notNull(principal, "'principal' must not be null"); - this.principal = principal; - return this; + return super.principal(principal); } - /** - * Set the remote address of the request. - * @param remoteAddress the remote address (IP) - * @since 6.0.10 - */ + @Override public MockHttpServletRequestBuilder remoteAddress(String remoteAddress) { - Assert.hasText(remoteAddress, "'remoteAddress' must not be null or blank"); - this.remoteAddress = remoteAddress; - return this; + return super.remoteAddress(remoteAddress); } - /** - * An extension point for further initialization of {@link MockHttpServletRequest} - * in ways not built directly into the {@code MockHttpServletRequestBuilder}. - * Implementation of this interface can have builder-style methods themselves - * and be made accessible through static factory methods. - * @param postProcessor a post-processor to add - */ @Override public MockHttpServletRequestBuilder with(RequestPostProcessor postProcessor) { - Assert.notNull(postProcessor, "postProcessor is required"); - this.postProcessors.add(postProcessor); - return this; - } - - - /** - * {@inheritDoc} - * @return always returns {@code true}. - */ - @Override - public boolean isMergeEnabled() { - return true; - } - - /** - * Merges the properties of the "parent" RequestBuilder accepting values - * only if not already set in "this" instance. - * @param parent the parent {@code RequestBuilder} to inherit properties from - * @return the result of the merge - */ - @Override - public Object merge(@Nullable Object parent) { - if (parent == null) { - return this; - } - if (!(parent instanceof MockHttpServletRequestBuilder parentBuilder)) { - throw new IllegalArgumentException("Cannot merge with [" + parent.getClass().getName() + "]"); - } - if (!StringUtils.hasText(this.contextPath)) { - this.contextPath = parentBuilder.contextPath; - } - if (!StringUtils.hasText(this.servletPath)) { - this.servletPath = parentBuilder.servletPath; - } - if ("".equals(this.pathInfo)) { - this.pathInfo = parentBuilder.pathInfo; - } - - if (this.secure == null) { - this.secure = parentBuilder.secure; - } - if (this.principal == null) { - this.principal = parentBuilder.principal; - } - if (this.session == null) { - this.session = parentBuilder.session; - } - if (this.remoteAddress == null) { - this.remoteAddress = parentBuilder.remoteAddress; - } - - if (this.characterEncoding == null) { - this.characterEncoding = parentBuilder.characterEncoding; - } - if (this.content == null) { - this.content = parentBuilder.content; - } - if (this.contentType == null) { - this.contentType = parentBuilder.contentType; - } - - for (Map.Entry> entry : parentBuilder.headers.entrySet()) { - String headerName = entry.getKey(); - if (!this.headers.containsKey(headerName)) { - this.headers.put(headerName, entry.getValue()); - } - } - for (Map.Entry> entry : parentBuilder.parameters.entrySet()) { - String paramName = entry.getKey(); - if (!this.parameters.containsKey(paramName)) { - this.parameters.put(paramName, entry.getValue()); - } - } - for (Map.Entry> entry : parentBuilder.queryParams.entrySet()) { - String paramName = entry.getKey(); - if (!this.queryParams.containsKey(paramName)) { - this.queryParams.put(paramName, entry.getValue()); - } - } - for (Map.Entry> entry : parentBuilder.formFields.entrySet()) { - String paramName = entry.getKey(); - if (!this.formFields.containsKey(paramName)) { - this.formFields.put(paramName, entry.getValue()); - } - } - for (Cookie cookie : parentBuilder.cookies) { - if (!containsCookie(cookie)) { - this.cookies.add(cookie); - } - } - for (Locale locale : parentBuilder.locales) { - if (!this.locales.contains(locale)) { - this.locales.add(locale); - } - } - - for (Map.Entry entry : parentBuilder.requestAttributes.entrySet()) { - String attributeName = entry.getKey(); - if (!this.requestAttributes.containsKey(attributeName)) { - this.requestAttributes.put(attributeName, entry.getValue()); - } - } - for (Map.Entry entry : parentBuilder.sessionAttributes.entrySet()) { - String attributeName = entry.getKey(); - if (!this.sessionAttributes.containsKey(attributeName)) { - this.sessionAttributes.put(attributeName, entry.getValue()); - } - } - for (Map.Entry entry : parentBuilder.flashAttributes.entrySet()) { - String attributeName = entry.getKey(); - if (!this.flashAttributes.containsKey(attributeName)) { - this.flashAttributes.put(attributeName, entry.getValue()); - } - } - - this.postProcessors.addAll(0, parentBuilder.postProcessors); - - return this; - } - - private boolean containsCookie(Cookie cookie) { - for (Cookie cookieToCheck : this.cookies) { - if (ObjectUtils.nullSafeEquals(cookieToCheck.getName(), cookie.getName())) { - return true; - } - } - return false; - } - - /** - * Build a {@link MockHttpServletRequest}. - */ - @Override - public final MockHttpServletRequest buildRequest(ServletContext servletContext) { - MockHttpServletRequest request = createServletRequest(servletContext); - - request.setAsyncSupported(true); - request.setMethod(this.method); - - String requestUri = this.url.getRawPath(); - request.setRequestURI(requestUri); - - if (this.url.getScheme() != null) { - request.setScheme(this.url.getScheme()); - } - if (this.url.getHost() != null) { - request.setServerName(this.url.getHost()); - } - if (this.url.getPort() != -1) { - request.setServerPort(this.url.getPort()); - } - - updatePathRequestProperties(request, requestUri); - - if (this.secure != null) { - request.setSecure(this.secure); - } - if (this.principal != null) { - request.setUserPrincipal(this.principal); - } - if (this.remoteAddress != null) { - request.setRemoteAddr(this.remoteAddress); - } - if (this.session != null) { - request.setSession(this.session); - } - - request.setCharacterEncoding(this.characterEncoding); - request.setContent(this.content); - request.setContentType(this.contentType); - - this.headers.forEach((name, values) -> { - for (Object value : values) { - request.addHeader(name, value); - } - }); - - if (!ObjectUtils.isEmpty(this.content) && - !this.headers.containsKey(HttpHeaders.CONTENT_LENGTH) && - !this.headers.containsKey(HttpHeaders.TRANSFER_ENCODING)) { - - request.addHeader(HttpHeaders.CONTENT_LENGTH, this.content.length); - } - - String query = this.url.getRawQuery(); - if (!this.queryParams.isEmpty()) { - String str = UriComponentsBuilder.newInstance().queryParams(this.queryParams).build().encode().getQuery(); - query = StringUtils.hasLength(query) ? (query + "&" + str) : str; - } - if (query != null) { - request.setQueryString(query); - } - addRequestParams(request, UriComponentsBuilder.fromUri(this.url).build().getQueryParams()); - - this.parameters.forEach((name, values) -> { - for (String value : values) { - request.addParameter(name, value); - } - }); - - if (!this.formFields.isEmpty()) { - if (this.content != null && this.content.length > 0) { - throw new IllegalStateException("Could not write form data with an existing body"); - } - Charset charset = (this.characterEncoding != null ? - Charset.forName(this.characterEncoding) : StandardCharsets.UTF_8); - MediaType mediaType = (request.getContentType() != null ? - MediaType.parseMediaType(request.getContentType()) : - new MediaType(MediaType.APPLICATION_FORM_URLENCODED, charset)); - if (!mediaType.isCompatibleWith(MediaType.APPLICATION_FORM_URLENCODED)) { - throw new IllegalStateException("Invalid content type: '" + mediaType + - "' is not compatible with '" + MediaType.APPLICATION_FORM_URLENCODED + "'"); - } - request.setContent(writeFormData(mediaType, charset)); - if (request.getContentType() == null) { - request.setContentType(mediaType.toString()); - } - } - if (this.content != null && this.content.length > 0) { - String requestContentType = request.getContentType(); - if (requestContentType != null) { - try { - MediaType mediaType = MediaType.parseMediaType(requestContentType); - if (MediaType.APPLICATION_FORM_URLENCODED.includes(mediaType)) { - addRequestParams(request, parseFormData(mediaType)); - } - } - catch (Exception ex) { - // Must be invalid, ignore.. - } - } - } - - if (!ObjectUtils.isEmpty(this.cookies)) { - request.setCookies(this.cookies.toArray(new Cookie[0])); - } - if (!ObjectUtils.isEmpty(this.locales)) { - request.setPreferredLocales(this.locales); - } - - this.requestAttributes.forEach(request::setAttribute); - this.sessionAttributes.forEach((name, attribute) -> { - HttpSession session = request.getSession(); - Assert.state(session != null, "No HttpSession"); - session.setAttribute(name, attribute); - }); - - FlashMap flashMap = new FlashMap(); - flashMap.putAll(this.flashAttributes); - FlashMapManager flashMapManager = getFlashMapManager(request); - flashMapManager.saveOutputFlashMap(flashMap, request, new MockHttpServletResponse()); - - return request; - } - - /** - * Create a new {@link MockHttpServletRequest} based on the supplied - * {@code ServletContext}. - *

    Can be overridden in subclasses. - */ - protected MockHttpServletRequest createServletRequest(ServletContext servletContext) { - return new MockHttpServletRequest(servletContext); - } - - /** - * Update the contextPath, servletPath, and pathInfo of the request. - */ - private void updatePathRequestProperties(MockHttpServletRequest request, String requestUri) { - if (!requestUri.startsWith(this.contextPath)) { - throw new IllegalArgumentException( - "Request URI [" + requestUri + "] does not start with context path [" + this.contextPath + "]"); - } - request.setContextPath(this.contextPath); - request.setServletPath(this.servletPath); - - if ("".equals(this.pathInfo)) { - if (!requestUri.startsWith(this.contextPath + this.servletPath)) { - throw new IllegalArgumentException( - "Invalid servlet path [" + this.servletPath + "] for request URI [" + requestUri + "]"); - } - String extraPath = requestUri.substring(this.contextPath.length() + this.servletPath.length()); - this.pathInfo = (StringUtils.hasText(extraPath) ? - UrlPathHelper.defaultInstance.decodeRequestString(request, extraPath) : null); - } - request.setPathInfo(this.pathInfo); - } - - private void addRequestParams(MockHttpServletRequest request, MultiValueMap map) { - map.forEach((key, values) -> values.forEach(value -> { - value = (value != null ? UriUtils.decode(value, StandardCharsets.UTF_8) : null); - request.addParameter(UriUtils.decode(key, StandardCharsets.UTF_8), value); - })); - } - - private byte[] writeFormData(MediaType mediaType, Charset charset) { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - HttpOutputMessage message = new HttpOutputMessage() { - @Override - public OutputStream getBody() { - return out; - } - - @Override - public HttpHeaders getHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(mediaType); - return headers; - } - }; - try { - FormHttpMessageConverter messageConverter = new FormHttpMessageConverter(); - messageConverter.setCharset(charset); - messageConverter.write(this.formFields, mediaType, message); - return out.toByteArray(); - } - catch (IOException ex) { - throw new IllegalStateException("Failed to write form data to request body", ex); - } - } - - private MultiValueMap parseFormData(MediaType mediaType) { - HttpInputMessage message = new HttpInputMessage() { - @Override - public InputStream getBody() { - return (content != null ? new ByteArrayInputStream(content) : InputStream.nullInputStream()); - } - @Override - public HttpHeaders getHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(mediaType); - return headers; - } - }; - - try { - return new FormHttpMessageConverter().read(null, message); - } - catch (IOException ex) { - throw new IllegalStateException("Failed to parse form data in request body", ex); - } - } - - private FlashMapManager getFlashMapManager(MockHttpServletRequest request) { - FlashMapManager flashMapManager = null; - try { - ServletContext servletContext = request.getServletContext(); - WebApplicationContext wac = WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext); - flashMapManager = wac.getBean(DispatcherServlet.FLASH_MAP_MANAGER_BEAN_NAME, FlashMapManager.class); - } - catch (IllegalStateException | NoSuchBeanDefinitionException ex) { - // ignore - } - return (flashMapManager != null ? flashMapManager : new SessionFlashMapManager()); - } - - @Override - public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { - for (RequestPostProcessor postProcessor : this.postProcessors) { - request = postProcessor.postProcessRequest(request); - } - return request; - } - - - private static void addToMap(Map map, String name, Object value) { - Assert.hasLength(name, "'name' must not be empty"); - Assert.notNull(value, "'value' must not be null"); - map.put(name, value); - } - - private static void addToMultiValueMap(MultiValueMap map, String name, T[] values) { - Assert.hasLength(name, "'name' must not be empty"); - Assert.notEmpty(values, "'values' must not be empty"); - for (T value : values) { - map.add(name, value); - } + return super.with(postProcessor); } } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockMultipartHttpServletRequestBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockMultipartHttpServletRequestBuilder.java index 576dc6c50cd4..4817e7fb4905 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockMultipartHttpServletRequestBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockMultipartHttpServletRequestBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +19,6 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.net.URI; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -45,6 +44,7 @@ * * @author Rossen Stoyanchev * @author Arjen Poutsma + * @author Stephane Nicoll * @since 3.2 */ public class MockMultipartHttpServletRequestBuilder extends MockHttpServletRequestBuilder { @@ -60,40 +60,19 @@ public class MockMultipartHttpServletRequestBuilder extends MockHttpServletReque *

    For other ways to initialize a {@code MockMultipartHttpServletRequest}, * see {@link #with(RequestPostProcessor)} and the * {@link RequestPostProcessor} extension point. - * @param urlTemplate a URL template; the resulting URL will be encoded - * @param uriVariables zero or more URI variables + * @param httpMethod the HTTP method (GET, POST, etc.) */ - MockMultipartHttpServletRequestBuilder(String urlTemplate, Object... uriVariables) { - this(HttpMethod.POST, urlTemplate, uriVariables); - } - - /** - * Variant of {@link #MockMultipartHttpServletRequestBuilder(String, Object...)} - * that also accepts an {@link HttpMethod}. - * @since 5.3.22 - */ - MockMultipartHttpServletRequestBuilder(HttpMethod httpMethod, String urlTemplate, Object... uriVariables) { - super(httpMethod, urlTemplate, uriVariables); + MockMultipartHttpServletRequestBuilder(HttpMethod httpMethod) { + super(httpMethod); super.contentType(MediaType.MULTIPART_FORM_DATA); } /** - * Variant of {@link #MockMultipartHttpServletRequestBuilder(String, Object...)} - * with a {@link URI}. - * @since 4.0.3 + * Variant of {@link #MockMultipartHttpServletRequestBuilder(HttpMethod)} + * that defaults to {@link HttpMethod#POST}. */ - MockMultipartHttpServletRequestBuilder(URI uri) { - this(HttpMethod.POST, uri); - } - - /** - * Variant of {@link #MockMultipartHttpServletRequestBuilder(String, Object...)} - * with a {@link URI} and an {@link HttpMethod}. - * @since 5.3.21 - */ - MockMultipartHttpServletRequestBuilder(HttpMethod httpMethod, URI uri) { - super(httpMethod, uri); - super.contentType(MediaType.MULTIPART_FORM_DATA); + MockMultipartHttpServletRequestBuilder() { + this(HttpMethod.POST); } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockMvcRequestBuilders.java b/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockMvcRequestBuilders.java index 077a93d92a74..00c0c54ceb21 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockMvcRequestBuilders.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockMvcRequestBuilders.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,202 +49,212 @@ public abstract class MockMvcRequestBuilders { /** * Create a {@link MockHttpServletRequestBuilder} for a GET request. - * @param urlTemplate a URL template; the resulting URL will be encoded + * @param uriTemplate a URI template; the resulting URI will be encoded * @param uriVariables zero or more URI variables */ - public static MockHttpServletRequestBuilder get(String urlTemplate, Object... uriVariables) { - return new MockHttpServletRequestBuilder(HttpMethod.GET, urlTemplate, uriVariables); + public static MockHttpServletRequestBuilder get(String uriTemplate, Object... uriVariables) { + return new MockHttpServletRequestBuilder(HttpMethod.GET).uri(uriTemplate, uriVariables); } /** * Create a {@link MockHttpServletRequestBuilder} for a GET request. - * @param uri the URL + * @param uri the URI * @since 4.0.3 */ public static MockHttpServletRequestBuilder get(URI uri) { - return new MockHttpServletRequestBuilder(HttpMethod.GET, uri); + return new MockHttpServletRequestBuilder(HttpMethod.GET).uri(uri); } /** * Create a {@link MockHttpServletRequestBuilder} for a POST request. - * @param urlTemplate a URL template; the resulting URL will be encoded + * @param uriTemplate a URI template; the resulting URI will be encoded * @param uriVariables zero or more URI variables */ - public static MockHttpServletRequestBuilder post(String urlTemplate, Object... uriVariables) { - return new MockHttpServletRequestBuilder(HttpMethod.POST, urlTemplate, uriVariables); + public static MockHttpServletRequestBuilder post(String uriTemplate, Object... uriVariables) { + return new MockHttpServletRequestBuilder(HttpMethod.POST).uri(uriTemplate, uriVariables); } /** * Create a {@link MockHttpServletRequestBuilder} for a POST request. - * @param uri the URL + * @param uri the URI * @since 4.0.3 */ public static MockHttpServletRequestBuilder post(URI uri) { - return new MockHttpServletRequestBuilder(HttpMethod.POST, uri); + return new MockHttpServletRequestBuilder(HttpMethod.POST).uri(uri); } /** * Create a {@link MockHttpServletRequestBuilder} for a PUT request. - * @param urlTemplate a URL template; the resulting URL will be encoded + * @param uriTemplate a URI template; the resulting URI will be encoded * @param uriVariables zero or more URI variables */ - public static MockHttpServletRequestBuilder put(String urlTemplate, Object... uriVariables) { - return new MockHttpServletRequestBuilder(HttpMethod.PUT, urlTemplate, uriVariables); + public static MockHttpServletRequestBuilder put(String uriTemplate, Object... uriVariables) { + return new MockHttpServletRequestBuilder(HttpMethod.PUT).uri(uriTemplate, uriVariables); } /** * Create a {@link MockHttpServletRequestBuilder} for a PUT request. - * @param uri the URL + * @param uri the URI * @since 4.0.3 */ public static MockHttpServletRequestBuilder put(URI uri) { - return new MockHttpServletRequestBuilder(HttpMethod.PUT, uri); + return new MockHttpServletRequestBuilder(HttpMethod.PUT).uri(uri); } /** * Create a {@link MockHttpServletRequestBuilder} for a PATCH request. - * @param urlTemplate a URL template; the resulting URL will be encoded + * @param uriTemplate a URI template; the resulting URI will be encoded * @param uriVariables zero or more URI variables */ - public static MockHttpServletRequestBuilder patch(String urlTemplate, Object... uriVariables) { - return new MockHttpServletRequestBuilder(HttpMethod.PATCH, urlTemplate, uriVariables); + public static MockHttpServletRequestBuilder patch(String uriTemplate, Object... uriVariables) { + return new MockHttpServletRequestBuilder(HttpMethod.PATCH).uri(uriTemplate, uriVariables); } /** * Create a {@link MockHttpServletRequestBuilder} for a PATCH request. - * @param uri the URL + * @param uri the URI * @since 4.0.3 */ public static MockHttpServletRequestBuilder patch(URI uri) { - return new MockHttpServletRequestBuilder(HttpMethod.PATCH, uri); + return new MockHttpServletRequestBuilder(HttpMethod.PATCH).uri(uri); } /** * Create a {@link MockHttpServletRequestBuilder} for a DELETE request. - * @param urlTemplate a URL template; the resulting URL will be encoded + * @param uriTemplate a URI template; the resulting URI will be encoded * @param uriVariables zero or more URI variables */ - public static MockHttpServletRequestBuilder delete(String urlTemplate, Object... uriVariables) { - return new MockHttpServletRequestBuilder(HttpMethod.DELETE, urlTemplate, uriVariables); + public static MockHttpServletRequestBuilder delete(String uriTemplate, Object... uriVariables) { + return new MockHttpServletRequestBuilder(HttpMethod.DELETE).uri(uriTemplate, uriVariables); } /** * Create a {@link MockHttpServletRequestBuilder} for a DELETE request. - * @param uri the URL + * @param uri the URI * @since 4.0.3 */ public static MockHttpServletRequestBuilder delete(URI uri) { - return new MockHttpServletRequestBuilder(HttpMethod.DELETE, uri); + return new MockHttpServletRequestBuilder(HttpMethod.DELETE).uri(uri); } /** * Create a {@link MockHttpServletRequestBuilder} for an OPTIONS request. - * @param urlTemplate a URL template; the resulting URL will be encoded + * @param uriTemplate a URI template; the resulting URI will be encoded * @param uriVariables zero or more URI variables */ - public static MockHttpServletRequestBuilder options(String urlTemplate, Object... uriVariables) { - return new MockHttpServletRequestBuilder(HttpMethod.OPTIONS, urlTemplate, uriVariables); + public static MockHttpServletRequestBuilder options(String uriTemplate, Object... uriVariables) { + return new MockHttpServletRequestBuilder(HttpMethod.OPTIONS).uri(uriTemplate, uriVariables); } /** * Create a {@link MockHttpServletRequestBuilder} for an OPTIONS request. - * @param uri the URL + * @param uri the URI * @since 4.0.3 */ public static MockHttpServletRequestBuilder options(URI uri) { - return new MockHttpServletRequestBuilder(HttpMethod.OPTIONS, uri); + return new MockHttpServletRequestBuilder(HttpMethod.OPTIONS).uri(uri); } /** * Create a {@link MockHttpServletRequestBuilder} for a HEAD request. - * @param urlTemplate a URL template; the resulting URL will be encoded + * @param uriTemplate a URI template; the resulting URI will be encoded * @param uriVariables zero or more URI variables * @since 4.1 */ - public static MockHttpServletRequestBuilder head(String urlTemplate, Object... uriVariables) { - return new MockHttpServletRequestBuilder(HttpMethod.HEAD, urlTemplate, uriVariables); + public static MockHttpServletRequestBuilder head(String uriTemplate, Object... uriVariables) { + return new MockHttpServletRequestBuilder(HttpMethod.HEAD).uri(uriTemplate, uriVariables); } /** * Create a {@link MockHttpServletRequestBuilder} for a HEAD request. - * @param uri the URL + * @param uri the URI * @since 4.1 */ public static MockHttpServletRequestBuilder head(URI uri) { - return new MockHttpServletRequestBuilder(HttpMethod.HEAD, uri); + return new MockHttpServletRequestBuilder(HttpMethod.HEAD).uri(uri); } /** * Create a {@link MockHttpServletRequestBuilder} for a request with the given HTTP method. - * @param method the HTTP method (GET, POST, etc) - * @param urlTemplate a URL template; the resulting URL will be encoded + * @param method the HTTP method (GET, POST, etc.) + * @param uriTemplate a URI template; the resulting URI will be encoded * @param uriVariables zero or more URI variables */ - public static MockHttpServletRequestBuilder request(HttpMethod method, String urlTemplate, Object... uriVariables) { - return new MockHttpServletRequestBuilder(method, urlTemplate, uriVariables); + public static MockHttpServletRequestBuilder request(HttpMethod method, String uriTemplate, Object... uriVariables) { + return new MockHttpServletRequestBuilder(method).uri(uriTemplate, uriVariables); } /** * Create a {@link MockHttpServletRequestBuilder} for a request with the given HTTP method. - * @param httpMethod the HTTP method (GET, POST, etc) - * @param uri the URL + * @param httpMethod the HTTP method (GET, POST, etc.) + * @param uri the URI * @since 4.0.3 */ public static MockHttpServletRequestBuilder request(HttpMethod httpMethod, URI uri) { - return new MockHttpServletRequestBuilder(httpMethod, uri); + return new MockHttpServletRequestBuilder(httpMethod).uri(uri); } /** - * Alternative factory method that allows for custom HTTP verbs (e.g. WebDAV). + * Alternative factory method that allows for custom HTTP verbs (for example, WebDAV). * @param httpMethod the HTTP method - * @param uri the URL + * @param uri the URI * @since 4.3 + * @deprecated in favor of {@link #request(HttpMethod, URI)} */ + @Deprecated(since = "6.2") public static MockHttpServletRequestBuilder request(String httpMethod, URI uri) { - return new MockHttpServletRequestBuilder(httpMethod, uri); + return request(HttpMethod.valueOf(httpMethod), uri); } /** * Create a {@link MockMultipartHttpServletRequestBuilder} for a multipart request, * using POST as the HTTP method. - * @param urlTemplate a URL template; the resulting URL will be encoded + * @param uriTemplate a URI template; the resulting URI will be encoded * @param uriVariables zero or more URI variables * @since 5.0 */ - public static MockMultipartHttpServletRequestBuilder multipart(String urlTemplate, Object... uriVariables) { - return new MockMultipartHttpServletRequestBuilder(urlTemplate, uriVariables); + public static MockMultipartHttpServletRequestBuilder multipart(String uriTemplate, Object... uriVariables) { + MockMultipartHttpServletRequestBuilder builder = new MockMultipartHttpServletRequestBuilder(); + builder.uri(uriTemplate, uriVariables); + return builder; } /** * Variant of {@link #multipart(String, Object...)} that also accepts an * {@link HttpMethod}. * @param httpMethod the HTTP method to use - * @param urlTemplate a URL template; the resulting URL will be encoded + * @param uriTemplate a URI template; the resulting URI will be encoded * @param uriVariables zero or more URI variables * @since 5.3.22 */ - public static MockMultipartHttpServletRequestBuilder multipart(HttpMethod httpMethod, String urlTemplate, Object... uriVariables) { - return new MockMultipartHttpServletRequestBuilder(httpMethod, urlTemplate, uriVariables); + public static MockMultipartHttpServletRequestBuilder multipart(HttpMethod httpMethod, String uriTemplate, Object... uriVariables) { + MockMultipartHttpServletRequestBuilder builder = new MockMultipartHttpServletRequestBuilder(httpMethod); + builder.uri(uriTemplate, uriVariables); + return builder; } /** * Variant of {@link #multipart(String, Object...)} with a {@link URI}. - * @param uri the URL + * @param uri the URI * @since 5.0 */ public static MockMultipartHttpServletRequestBuilder multipart(URI uri) { - return new MockMultipartHttpServletRequestBuilder(uri); + MockMultipartHttpServletRequestBuilder builder = new MockMultipartHttpServletRequestBuilder(); + builder.uri(uri); + return builder; } /** * Variant of {@link #multipart(String, Object...)} with a {@link URI} and * an {@link HttpMethod}. * @param httpMethod the HTTP method to use - * @param uri the URL + * @param uri the URI * @since 5.3.21 */ public static MockMultipartHttpServletRequestBuilder multipart(HttpMethod httpMethod, URI uri) { - return new MockMultipartHttpServletRequestBuilder(httpMethod, uri); + MockMultipartHttpServletRequestBuilder builder = new MockMultipartHttpServletRequestBuilder(httpMethod); + builder.uri(uri); + return builder; } /** diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/result/ContentResultMatchers.java b/spring-test/src/main/java/org/springframework/test/web/servlet/result/ContentResultMatchers.java index dcd08ba6de29..fa3c9dcf7631 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/result/ContentResultMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/result/ContentResultMatchers.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,10 @@ import org.w3c.dom.Node; import org.springframework.http.MediaType; -import org.springframework.test.util.JsonExpectationsHelper; +import org.springframework.test.json.JsonAssert; +import org.springframework.test.json.JsonComparator; +import org.springframework.test.json.JsonCompareMode; +import org.springframework.test.json.JsonComparison; import org.springframework.test.util.XmlExpectationsHelper; import org.springframework.test.web.servlet.ResultMatcher; @@ -51,8 +54,6 @@ public class ContentResultMatchers { private final XmlExpectationsHelper xmlHelper; - private final JsonExpectationsHelper jsonHelper; - /** * Protected constructor. @@ -60,7 +61,6 @@ public class ContentResultMatchers { */ protected ContentResultMatchers() { this.xmlHelper = new XmlExpectationsHelper(); - this.jsonHelper = new JsonExpectationsHelper(); } @@ -198,13 +198,16 @@ public ResultMatcher source(Matcher matcher) { /** * Parse the expected and actual strings as JSON and assert the two * are "similar" - i.e. they contain the same attribute-value pairs - * regardless of formatting with a lenient checking (extensible, and non-strict array - * ordering). + * regardless of formatting with a lenient checking (extensible, + * and non-strict array ordering). + *

    Use of this matcher requires the JSONassert library. * @param jsonContent the expected JSON content * @since 4.1 + * @see #json(String, JsonCompareMode) */ public ResultMatcher json(String jsonContent) { - return json(jsonContent, false); + return json(jsonContent, JsonCompareMode.LENIENT); } /** @@ -220,11 +223,42 @@ public ResultMatcher json(String jsonContent) { * @param jsonContent the expected JSON content * @param strict enables strict checking * @since 4.2 + * @deprecated in favor of {@link #json(String, JsonCompareMode)} */ + @Deprecated(since = "6.2") public ResultMatcher json(String jsonContent, boolean strict) { + JsonCompareMode compareMode = (strict ? JsonCompareMode.STRICT : JsonCompareMode.LENIENT); + return json(jsonContent, compareMode); + } + + /** + * Parse the response content and the given string as JSON and assert the two + * using the given {@linkplain JsonCompareMode mode}. If the comparison failed, + * throws an {@link AssertionError} with the message of the {@link JsonComparison}. + *

    Use of this matcher requires the JSONassert library. + * @param jsonContent the expected JSON content + * @param compareMode the compare mode + * @since 6.2 + */ + public ResultMatcher json(String jsonContent, JsonCompareMode compareMode) { + return json(jsonContent, JsonAssert.comparator(compareMode)); + } + + /** + * Parse the response content and the given string as JSON and assert the two + * using the given {@link JsonComparator}. If the comparison failed, throws an + * {@link AssertionError} with the message of the {@link JsonComparison}. + *

    Use this matcher if you require a custom JSONAssert configuration or + * if you desire to use another assertion library. + * @param jsonContent the expected JSON content + * @param comparator the comparator to use + * @since 6.2 + */ + public ResultMatcher json(String jsonContent, JsonComparator comparator) { return result -> { - String content = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - this.jsonHelper.assertJsonEqual(jsonContent, content, strict); + String content = result.getResponse().getContentAsString(); + comparator.assertIsMatch(jsonContent, content); }; } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/result/CookieResultMatchers.java b/spring-test/src/main/java/org/springframework/test/web/servlet/result/CookieResultMatchers.java index 88ed2f04ed15..01a346d36e42 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/result/CookieResultMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/result/CookieResultMatchers.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -229,6 +229,17 @@ public ResultMatcher httpOnly(String name, boolean httpOnly) { }; } + /** + * Assert whether the cookie is partitioned. + * @since 6.2 + */ + public ResultMatcher partitioned(String name, boolean partitioned) { + return result -> { + Cookie cookie = getCookie(result, name); + assertEquals("Response cookie '" + name + "' partitioned", partitioned, cookie.getAttribute("Partitioned") != null); + }; + } + /** * Assert a cookie's specified attribute with a Hamcrest {@link Matcher}. * @param cookieAttribute the name of the Cookie attribute (case-insensitive) diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/result/JsonPathResultMatchers.java b/spring-test/src/main/java/org/springframework/test/web/servlet/result/JsonPathResultMatchers.java index a0d882e2d56d..01e722fffd8b 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/result/JsonPathResultMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/result/JsonPathResultMatchers.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +17,6 @@ package org.springframework.test.web.servlet.result; import java.io.UnsupportedEncodingException; -import java.nio.charset.StandardCharsets; import com.jayway.jsonpath.JsonPath; import org.hamcrest.Matcher; @@ -28,6 +27,7 @@ import org.springframework.test.util.JsonPathExpectationsHelper; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** @@ -60,7 +60,8 @@ public class JsonPathResultMatchers { * using formatting specifiers defined in {@link String#format(String, Object...)} */ protected JsonPathResultMatchers(String expression, Object... args) { - this.jsonPathHelper = new JsonPathExpectationsHelper(expression, args); + Assert.hasText(expression, "expression must not be null or empty"); + this.jsonPathHelper = new JsonPathExpectationsHelper(expression.formatted(args)); } /** @@ -236,7 +237,7 @@ public ResultMatcher isMap() { } private String getContent(MvcResult result) throws UnsupportedEncodingException { - String content = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + String content = result.getResponse().getContentAsString(); if (StringUtils.hasLength(this.prefix)) { try { String reason = String.format("Expected a JSON payload prefixed with \"%s\" but found: %s", diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/result/PrintingResultHandler.java b/spring-test/src/main/java/org/springframework/test/web/servlet/result/PrintingResultHandler.java index bccce3305613..61952f737389 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/result/PrintingResultHandler.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/result/PrintingResultHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,6 @@ package org.springframework.test.web.servlet.result; -import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.Enumeration; import java.util.Map; @@ -28,7 +27,6 @@ import org.springframework.core.style.ToStringCreator; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.lang.Nullable; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; @@ -209,7 +207,7 @@ protected void printResolvedException(@Nullable Exception resolvedException) thr protected void printModelAndView(@Nullable ModelAndView mav) throws Exception { this.printer.printValue("View name", (mav != null) ? mav.getViewName() : null); this.printer.printValue("View", (mav != null) ? mav.getView() : null); - if (mav == null || mav.getModel().size() == 0) { + if (mav == null || mav.getModel().isEmpty()) { this.printer.printValue("Model", null); } else { @@ -250,9 +248,7 @@ protected void printResponse(MockHttpServletResponse response) throws Exception this.printer.printValue("Error message", response.getErrorMessage()); this.printer.printValue("Headers", getResponseHeaders(response)); this.printer.printValue("Content type", response.getContentType()); - String body = (MediaType.APPLICATION_JSON_VALUE.equals(response.getContentType()) ? - response.getContentAsString(StandardCharsets.UTF_8) : response.getContentAsString()); - this.printer.printValue("Body", body); + this.printer.printValue("Body", response.getContentAsString()); this.printer.printValue("Forwarded URL", response.getForwardedUrl()); this.printer.printValue("Redirected URL", response.getRedirectedUrl()); printCookies(response.getCookies()); diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/result/StatusResultMatchers.java b/spring-test/src/main/java/org/springframework/test/web/servlet/result/StatusResultMatchers.java index f39d6a9b049c..65fc10f94726 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/result/StatusResultMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/result/StatusResultMatchers.java @@ -22,6 +22,7 @@ import org.springframework.http.HttpStatusCode; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.util.Assert; import static org.hamcrest.MatcherAssert.assertThat; import static org.springframework.test.util.AssertionErrors.assertEquals; @@ -105,7 +106,9 @@ public ResultMatcher is5xxServerError() { } private HttpStatus.Series getHttpStatusSeries(MvcResult result) { - return HttpStatus.Series.resolve(result.getResponse().getStatus()); + HttpStatus.Series series = HttpStatus.Series.resolve(result.getResponse().getStatus()); + Assert.state(series != null, "HTTP status series must not be null"); + return series; } /** diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/ConfigurableMockMvcBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/ConfigurableMockMvcBuilder.java index 4a4db80e079c..40de7774f21d 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/ConfigurableMockMvcBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/ConfigurableMockMvcBuilder.java @@ -136,7 +136,7 @@ default T defaultResponseCharacterEncoding(Charset defaultResponse /** * Add a {@code MockMvcConfigurer} that automates MockMvc setup and - * configures it for some specific purpose (e.g. security). + * configures it for some specific purpose (for example, security). *

    There is a built-in {@link SharedHttpSessionConfigurer} that can be * used to re-use the HTTP session across requests. 3rd party frameworks * like Spring Security also use this mechanism to provide configuration diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcBuilders.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcBuilders.java index 71b523d7971d..09520cee3efa 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcBuilders.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcBuilders.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2024 the original author 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 org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvcBuilder; import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.function.RouterFunction; /** * The main class to import in order to access all available {@link MockMvcBuilder MockMvcBuilders}. @@ -76,4 +77,23 @@ public static StandaloneMockMvcBuilder standaloneSetup(Object... controllers) { return new StandaloneMockMvcBuilder(controllers); } + /** + * Build a {@link MockMvc} instance by registering one or more + * {@link RouterFunction RouterFunction} instances and configuring Spring + * MVC infrastructure programmatically. + *

    This allows full control over the instantiation and initialization of + * router functions and their dependencies, similar to plain unit tests while + * also making it possible to test one router function at a time. + *

    When this builder is used, the minimum infrastructure required by the + * {@link org.springframework.web.servlet.DispatcherServlet DispatcherServlet} + * to serve requests with router functions is created automatically + * and can be customized, resulting in configuration that is equivalent to + * what MVC Java configuration provides except using builder-style methods. + * @param routerFunctions one or more {@code RouterFunction} instances to test + * @since 6.2 + */ + public static RouterFunctionMockMvcBuilder routerFunctions(RouterFunction... routerFunctions) { + return new RouterFunctionMockMvcBuilder(routerFunctions); + } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcConfigurer.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcConfigurer.java index e3edfe6f1c23..b7707efde915 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcConfigurer.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcConfigurer.java @@ -22,12 +22,12 @@ /** * Contract for customizing a {@code ConfigurableMockMvcBuilder} in some - * specific way, e.g. a 3rd party library that wants to provide shortcuts for + * specific way, for example, a 3rd party library that wants to provide shortcuts for * setting up a MockMvc. * *

    An implementation of this interface can be plugged in via * {@link ConfigurableMockMvcBuilder#apply} with instances of this type likely - * created via static methods, e.g.: + * created via static methods, for example: * *

      * import static org.example.ExampleSetup.mySetup;
    diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcFilterDecorator.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcFilterDecorator.java
    index 4574618628d5..f07720d4abc3 100644
    --- a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcFilterDecorator.java
    +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcFilterDecorator.java
    @@ -1,5 +1,5 @@
     /*
    - * Copyright 2002-2023 the original author or authors.
    + * Copyright 2002-2024 the original author 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 @@
     
     import org.springframework.lang.Nullable;
     import org.springframework.mock.web.MockFilterConfig;
    +import org.springframework.mock.web.MockFilterRegistration;
    +import org.springframework.mock.web.MockServletContext;
     import org.springframework.util.Assert;
     import org.springframework.web.util.UrlPathHelper;
     
    @@ -64,13 +66,13 @@ final class MockMvcFilterDecorator implements Filter {
     
     	private final boolean hasPatterns;
     
    -	/** Patterns that require an exact match, e.g. "/test" */
    +	/** Patterns that require an exact match, for example, "/test". */
     	private final List exactMatches = new ArrayList<>();
     
    -	/** Patterns that require the URL to have a specific prefix, e.g. "/test/*" */
    +	/** Patterns that require the URL to have a specific prefix, for example, "/test/*". */
     	private final List startsWithMatches = new ArrayList<>();
     
    -	/** Patterns that require the request URL to have a specific suffix, e.g. "*.html" */
    +	/** Patterns that require the request URL to have a specific suffix, for example, "*.html". */
     	private final List endsWithMatches = new ArrayList<>();
     
     
    @@ -98,20 +100,29 @@ public MockMvcFilterDecorator(
     		Assert.notNull(delegate, "filter cannot be null");
     		Assert.notNull(urlPatterns, "urlPatterns cannot be null");
     		this.delegate = delegate;
    -		this.filterConfigInitializer = getFilterConfigInitializer(filterName, initParams);
    +		this.filterConfigInitializer = getFilterConfigInitializer(delegate, filterName, initParams);
     		this.dispatcherTypes = dispatcherTypes;
     		this.hasPatterns = initPatterns(urlPatterns);
     	}
     
     	private static Function getFilterConfigInitializer(
    -			@Nullable String filterName, @Nullable Map initParams) {
    +			Filter delegate, @Nullable String filterName, @Nullable Map initParams) {
    +
    +		String className = delegate.getClass().getName();
     
     		return servletContext -> {
     			MockFilterConfig filterConfig = (filterName != null ?
     					new MockFilterConfig(servletContext, filterName) : new MockFilterConfig(servletContext));
    +
     			if (initParams != null) {
     				initParams.forEach(filterConfig::addInitParameter);
     			}
    +
    +			if (servletContext instanceof MockServletContext mockServletContext) {
    +				mockServletContext.addFilterRegistration(filterName != null ?
    +						new MockFilterRegistration(className, filterName) : new MockFilterRegistration(className));
    +			}
    +
     			return filterConfig;
     		};
     	}
    diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/RouterFunctionMockMvcBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/RouterFunctionMockMvcBuilder.java
    new file mode 100644
    index 000000000000..079b363a2695
    --- /dev/null
    +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/RouterFunctionMockMvcBuilder.java
    @@ -0,0 +1,321 @@
    +/*
    + * Copyright 2002-2024 the original author or authors.
    + *
    + * Licensed under the Apache License, Version 2.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.setup;
    +
    +import java.util.ArrayList;
    +import java.util.Arrays;
    +import java.util.Collections;
    +import java.util.List;
    +import java.util.function.Supplier;
    +
    +import jakarta.servlet.ServletContext;
    +
    +import org.springframework.beans.factory.InitializingBean;
    +import org.springframework.context.ApplicationContext;
    +import org.springframework.context.ApplicationContextAware;
    +import org.springframework.format.support.FormattingConversionService;
    +import org.springframework.http.converter.HttpMessageConverter;
    +import org.springframework.lang.Nullable;
    +import org.springframework.mock.web.MockServletContext;
    +import org.springframework.util.Assert;
    +import org.springframework.web.accept.ContentNegotiationManager;
    +import org.springframework.web.context.WebApplicationContext;
    +import org.springframework.web.context.support.WebApplicationObjectSupport;
    +import org.springframework.web.servlet.DispatcherServlet;
    +import org.springframework.web.servlet.HandlerExceptionResolver;
    +import org.springframework.web.servlet.HandlerInterceptor;
    +import org.springframework.web.servlet.View;
    +import org.springframework.web.servlet.ViewResolver;
    +import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
    +import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
    +import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
    +import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
    +import org.springframework.web.servlet.function.RouterFunction;
    +import org.springframework.web.servlet.function.support.HandlerFunctionAdapter;
    +import org.springframework.web.servlet.function.support.RouterFunctionMapping;
    +import org.springframework.web.servlet.handler.MappedInterceptor;
    +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
    +import org.springframework.web.servlet.resource.ResourceUrlProvider;
    +import org.springframework.web.servlet.view.InternalResourceViewResolver;
    +import org.springframework.web.util.pattern.PathPatternParser;
    +
    +/**
    + * A {@code MockMvcBuilder} that accepts {@link RouterFunction} registrations
    + * thus allowing full control over the instantiation and initialization of
    + * router functions and their dependencies similar to plain unit tests, and also
    + * making it possible to test one function at a time.
    + *
    + * 

    This builder creates the minimum infrastructure required by the + * {@link DispatcherServlet} to serve requests with router functions and + * also provides methods for customization. The resulting configuration and + * customization options are equivalent to using MVC Java config except + * using builder style methods. + * + *

    To configure view resolution, either select a "fixed" view to use for every + * request performed (see {@link #setSingleView(View)}) or provide a list of + * {@code ViewResolver}s (see {@link #setViewResolvers(ViewResolver...)}). + * + * @author Arjen Poutsma + * @since 6.2 + */ +public class RouterFunctionMockMvcBuilder extends AbstractMockMvcBuilder { + + private final RouterFunction routerFunction; + + private List> messageConverters = new ArrayList<>(); + + private final List mappedInterceptors = new ArrayList<>(); + + @Nullable + private List handlerExceptionResolvers; + + @Nullable + private Long asyncRequestTimeout; + + @Nullable + private List viewResolvers; + + @Nullable + private PathPatternParser patternParser; + + private Supplier handlerMappingFactory = RouterFunctionMapping::new; + + + protected RouterFunctionMockMvcBuilder(RouterFunction... routerFunctions) { + Assert.notEmpty(routerFunctions, "RouterFunctions must not be empty"); + + this.routerFunction = Arrays.stream(routerFunctions).reduce(RouterFunction::andOther).orElseThrow(); + } + + + /** + * Set the message converters to use in argument resolvers and in return value + * handlers, which support reading and/or writing to the body of the request + * and response. If no message converters are added to the list, a default + * list of converters is added instead. + */ + public RouterFunctionMockMvcBuilder setMessageConverters(HttpMessageConverter...messageConverters) { + this.messageConverters = Arrays.asList(messageConverters); + return this; + } + + /** + * Add interceptors mapped to all incoming requests. + */ + public RouterFunctionMockMvcBuilder addInterceptors(HandlerInterceptor... interceptors) { + addMappedInterceptors(null, interceptors); + return this; + } + + /** + * Add interceptors mapped to a set of path patterns. + */ + public RouterFunctionMockMvcBuilder addMappedInterceptors(@Nullable String[] pathPatterns, + HandlerInterceptor... interceptors) { + + for (HandlerInterceptor interceptor : interceptors) { + this.mappedInterceptors.add(new MappedInterceptor(pathPatterns, null, interceptor)); + } + return this; + } + + /** + * Set the HandlerExceptionResolver types to use as a list. + */ + public RouterFunctionMockMvcBuilder setHandlerExceptionResolvers(List exceptionResolvers) { + this.handlerExceptionResolvers = exceptionResolvers; + return this; + } + + /** + * Set the HandlerExceptionResolver types to use as an array. + */ + public RouterFunctionMockMvcBuilder setHandlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers) { + this.handlerExceptionResolvers = Arrays.asList(exceptionResolvers); + return this; + } + + /** + * Configure the factory to create a custom {@link RequestMappingHandlerMapping}. + * @param factory the factory + */ + public RouterFunctionMockMvcBuilder setCustomHandlerMapping(Supplier factory) { + this.handlerMappingFactory = factory; + return this; + } + + /** + * Set up view resolution with the given {@link ViewResolver ViewResolvers}. + *

    If not set, an {@link InternalResourceViewResolver} is used by default. + */ + public RouterFunctionMockMvcBuilder setViewResolvers(ViewResolver...resolvers) { + this.viewResolvers = Arrays.asList(resolvers); + return this; + } + + /** + * Set up a single {@link ViewResolver} that always returns the provided + * view instance. + *

    This is a convenient shortcut if you need to use one {@link View} + * instance only — for example, rendering generated content (JSON, XML, + * Atom). + */ + public RouterFunctionMockMvcBuilder setSingleView(View view) { + this.viewResolvers = Collections.singletonList(new StaticViewResolver(view)); + return this; + } + + /** + * Specify the timeout value for async execution. + *

    In Spring MVC Test, this value is used to determine how long to wait + * for async execution to complete so that a test can verify the results + * synchronously. + * @param timeout the timeout value in milliseconds + */ + public RouterFunctionMockMvcBuilder setAsyncRequestTimeout(long timeout) { + this.asyncRequestTimeout = timeout; + return this; + } + + /** + * Enable URL path matching with parsed + * {@link org.springframework.web.util.pattern.PathPattern PathPatterns} + * instead of String pattern matching with a {@link org.springframework.util.PathMatcher}. + * @param parser the parser to use + */ + public RouterFunctionMockMvcBuilder setPatternParser(@Nullable PathPatternParser parser) { + this.patternParser = parser; + return this; + } + + + @Override + protected WebApplicationContext initWebAppContext() { + MockServletContext servletContext = new MockServletContext(); + StubWebApplicationContext wac = new StubWebApplicationContext(servletContext); + registerRouterFunction(wac); + servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, wac); + return wac; + } + + private void registerRouterFunction(StubWebApplicationContext wac) { + HandlerFunctionConfiguration config = new HandlerFunctionConfiguration(); + config.setApplicationContext(wac); + ServletContext sc = wac.getServletContext(); + + wac.addBean("routerFunction", this.routerFunction); + + FormattingConversionService mvcConversionService = config.mvcConversionService(); + wac.addBean("mvcConversionService", mvcConversionService); + ResourceUrlProvider resourceUrlProvider = config.mvcResourceUrlProvider(); + wac.addBean("mvcResourceUrlProvider", resourceUrlProvider); + ContentNegotiationManager mvcContentNegotiationManager = config.mvcContentNegotiationManager(); + wac.addBean("mvcContentNegotiationManager", mvcContentNegotiationManager); + + RouterFunctionMapping hm = config.getHandlerMapping(mvcConversionService, resourceUrlProvider); + if (sc != null) { + hm.setServletContext(sc); + } + hm.setApplicationContext(wac); + hm.afterPropertiesSet(); + wac.addBean("routerFunctionMapping", hm); + + HandlerFunctionAdapter ha = config.handlerFunctionAdapter(); + wac.addBean("handlerFunctionAdapter", ha); + + wac.addBean("handlerExceptionResolver", config.handlerExceptionResolver(mvcContentNegotiationManager)); + + wac.addBeans(initViewResolvers(wac)); + } + + private List initViewResolvers(WebApplicationContext wac) { + this.viewResolvers = (this.viewResolvers != null ? this.viewResolvers : + Collections.singletonList(new InternalResourceViewResolver())); + for (Object viewResolver : this.viewResolvers) { + if (viewResolver instanceof WebApplicationObjectSupport support) { + support.setApplicationContext(wac); + } + } + return this.viewResolvers; + } + + + /** Using the MVC Java configuration as the starting point for the "standalone" setup. */ + private class HandlerFunctionConfiguration extends WebMvcConfigurationSupport { + + public RouterFunctionMapping getHandlerMapping( + FormattingConversionService mvcConversionService, + ResourceUrlProvider mvcResourceUrlProvider) { + + RouterFunctionMapping handlerMapping = handlerMappingFactory.get(); + handlerMapping.setOrder(0); + handlerMapping.setInterceptors(getInterceptors(mvcConversionService, mvcResourceUrlProvider)); + handlerMapping.setMessageConverters(getMessageConverters()); + if (patternParser != null) { + handlerMapping.setPatternParser(patternParser); + } + return handlerMapping; + } + + @Override + protected void configureMessageConverters(List> converters) { + converters.addAll(messageConverters); + } + + @Override + protected void addInterceptors(InterceptorRegistry registry) { + for (MappedInterceptor interceptor : mappedInterceptors) { + InterceptorRegistration registration = registry.addInterceptor(interceptor.getInterceptor()); + if (interceptor.getIncludePathPatterns() != null) { + registration.addPathPatterns(interceptor.getIncludePathPatterns()); + } + } + } + + @Override + public void configureAsyncSupport(AsyncSupportConfigurer configurer) { + if (asyncRequestTimeout != null) { + configurer.setDefaultTimeout(asyncRequestTimeout); + } + } + + @Override + protected void configureHandlerExceptionResolvers(List exceptionResolvers) { + if (handlerExceptionResolvers == null) { + return; + } + for (HandlerExceptionResolver resolver : handlerExceptionResolvers) { + if (resolver instanceof ApplicationContextAware applicationContextAware) { + ApplicationContext applicationContext = getApplicationContext(); + if (applicationContext != null) { + applicationContextAware.setApplicationContext(applicationContext); + } + } + if (resolver instanceof InitializingBean initializingBean) { + try { + initializingBean.afterPropertiesSet(); + } + catch (Exception ex) { + throw new IllegalStateException("Failure from afterPropertiesSet", ex); + } + } + exceptionResolvers.add(resolver); + } + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilder.java index d47c3bb94efe..4c68fffef1cb 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +21,6 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.function.Supplier; @@ -133,7 +132,7 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuildersingletonList(new StaticViewResolver(view)); @@ -586,7 +585,7 @@ private static class StaticStringValueResolver implements StringValueResolver { private final PlaceholderResolver resolver; public StaticStringValueResolver(Map values) { - this.helper = new PropertyPlaceholderHelper("${", "}", ":", false); + this.helper = new PropertyPlaceholderHelper("${", "}", ":", null, false); this.resolver = values::get; } @@ -596,23 +595,4 @@ public String resolveStringValue(String strVal) throws BeansException { } } - - /** - * A {@link ViewResolver} that always returns same View. - */ - private static class StaticViewResolver implements ViewResolver { - - private final View view; - - public StaticViewResolver(View view) { - this.view = view; - } - - @Override - @Nullable - public View resolveViewName(String viewName, Locale locale) { - return this.view; - } - } - } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StaticViewResolver.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StaticViewResolver.java new file mode 100644 index 000000000000..87c4bc37bfdc --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StaticViewResolver.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.setup; + +import java.util.Locale; + +import org.springframework.lang.Nullable; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.ViewResolver; + +/** + * A {@link ViewResolver} that always returns same View. + * + * @author Rob Winch + * @since 6.2 + */ +class StaticViewResolver implements ViewResolver { + + private final View view; + + public StaticViewResolver(View view) { + this.view = view; + } + + @Override + @Nullable + public View resolveViewName(String viewName, Locale locale) { + return this.view; + } +} diff --git a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockHttpServletRequestDsl.kt b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockHttpServletRequestDsl.kt index 392981144699..a85a3f3d7746 100644 --- a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockHttpServletRequestDsl.kt +++ b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockHttpServletRequestDsl.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,6 +25,7 @@ import org.springframework.util.MultiValueMap import java.security.Principal import java.util.* import jakarta.servlet.http.Cookie +import org.springframework.test.web.servlet.request.AbstractMockHttpServletRequestBuilder /** * Provide a [MockHttpServletRequestBuilder] Kotlin DSL in order to be able to write idiomatic Kotlin code. @@ -40,7 +41,7 @@ import jakarta.servlet.http.Cookie * @author Sebastien Deleuze * @since 5.2 */ -open class MockHttpServletRequestDsl internal constructor (private val builder: MockHttpServletRequestBuilder) { +open class MockHttpServletRequestDsl(private val builder: AbstractMockHttpServletRequestBuilder<*>) { /** * @see [MockHttpServletRequestBuilder.contextPath] @@ -128,6 +129,18 @@ open class MockHttpServletRequestDsl internal constructor (private val builder: */ var queryParams: MultiValueMap? = null + /** + * @see [MockHttpServletRequestBuilder.formField] + */ + fun formField(name: String, vararg values: String) { + builder.formField(name, *values) + } + + /** + * @see [MockHttpServletRequestBuilder.formFields] + */ + var formFields: MultiValueMap? = null + /** * @see [MockHttpServletRequestBuilder.cookie] */ @@ -214,6 +227,7 @@ open class MockHttpServletRequestDsl internal constructor (private val builder: contentType?.also { builder.contentType(it) } params?.also { builder.params(it) } queryParams?.also { builder.queryParams(it) } + formFields?.also { builder.formFields(it) } sessionAttrs?.also { builder.sessionAttrs(it) } flashAttrs?.also { builder.flashAttrs(it) } session?.also { builder.session(it) } diff --git a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockMultipartHttpServletRequestDsl.kt b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockMultipartHttpServletRequestDsl.kt index e2b3ad565cf7..827174c079c8 100644 --- a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockMultipartHttpServletRequestDsl.kt +++ b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockMultipartHttpServletRequestDsl.kt @@ -27,7 +27,7 @@ import jakarta.servlet.http.Part * @author Sebastien Deleuze * @since 5.2 */ -class MockMultipartHttpServletRequestDsl internal constructor (private val builder: MockMultipartHttpServletRequestBuilder) : MockHttpServletRequestDsl(builder) { +class MockMultipartHttpServletRequestDsl(private val builder: MockMultipartHttpServletRequestBuilder) : MockHttpServletRequestDsl(builder) { /** * @see [MockMultipartHttpServletRequestBuilder.file] diff --git a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockMvcResultHandlersDsl.kt b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockMvcResultHandlersDsl.kt index c493b450de7d..72727ed14b20 100644 --- a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockMvcResultHandlersDsl.kt +++ b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockMvcResultHandlersDsl.kt @@ -26,7 +26,7 @@ import java.io.Writer * @author Sebastien Deleuze * @since 5.2 */ -class MockMvcResultHandlersDsl internal constructor (private val actions: ResultActions) { +class MockMvcResultHandlersDsl(private val actions: ResultActions) { /** * @see MockMvcResultHandlers.print diff --git a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockMvcResultMatchersDsl.kt b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockMvcResultMatchersDsl.kt index 1a65758d76b2..810285d15313 100644 --- a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockMvcResultMatchersDsl.kt +++ b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/MockMvcResultMatchersDsl.kt @@ -25,7 +25,7 @@ import org.springframework.test.web.servlet.result.* * @author Sebastien Deleuze * @since 5.2 */ -class MockMvcResultMatchersDsl internal constructor (private val actions: ResultActions) { +class MockMvcResultMatchersDsl(private val actions: ResultActions) { /** * @see MockMvcResultMatchers.request diff --git a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/ResultActionsDsl.kt b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/ResultActionsDsl.kt index 5d413702467d..e60dfba6b028 100644 --- a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/ResultActionsDsl.kt +++ b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/ResultActionsDsl.kt @@ -8,7 +8,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders * @author Sebastien Deleuze * @since 5.2 */ -class ResultActionsDsl internal constructor (private val actions: ResultActions, private val mockMvc: MockMvc) { +class ResultActionsDsl(private val actions: ResultActions, private val mockMvc: MockMvc) { /** * Provide access to [MockMvcResultMatchersDsl] Kotlin DSL. @@ -19,7 +19,6 @@ class ResultActionsDsl internal constructor (private val actions: ResultActions, return this } - /** * Provide access to [MockMvcResultMatchersDsl] Kotlin DSL. * @since 6.0.4 diff --git a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/ContentResultMatchersDsl.kt b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/ContentResultMatchersDsl.kt index 2ef41fdf1a01..f8322e40ddff 100644 --- a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/ContentResultMatchersDsl.kt +++ b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/ContentResultMatchersDsl.kt @@ -18,6 +18,7 @@ package org.springframework.test.web.servlet.result import org.hamcrest.Matcher import org.springframework.http.MediaType +import org.springframework.test.json.JsonCompareMode import org.springframework.test.web.servlet.ResultActions import org.w3c.dom.Node import javax.xml.transform.Source @@ -112,7 +113,16 @@ class ContentResultMatchersDsl internal constructor (private val actions: Result /** * @see ContentResultMatchers.json */ - fun json(jsonContent: String, strict: Boolean = false) { - actions.andExpect(matchers.json(jsonContent, strict)) + @Deprecated(message = "Use JsonCompare mode instead") + fun json(jsonContent: String, strict: Boolean) { + val compareMode = (if (strict) JsonCompareMode.STRICT else JsonCompareMode.LENIENT) + actions.andExpect(matchers.json(jsonContent, compareMode)) + } + + /** + * @see ContentResultMatchers.json + */ + fun json(jsonContent: String, compareMode: JsonCompareMode = JsonCompareMode.LENIENT) { + actions.andExpect(matchers.json(jsonContent, compareMode)) } } diff --git a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/CookieResultMatchersDsl.kt b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/CookieResultMatchersDsl.kt index 1c18645e53a8..6a9ee8733542 100644 --- a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/CookieResultMatchersDsl.kt +++ b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/CookieResultMatchersDsl.kt @@ -157,6 +157,14 @@ class CookieResultMatchersDsl internal constructor (private val actions: ResultA actions.andExpect(matchers.httpOnly(name, httpOnly)) } + /** + * @see CookieResultMatchers.partitioned + * @since 6.2 + */ + fun partitioned(name: String, partitioned: Boolean) { + actions.andExpect(matchers.partitioned(name, partitioned)) + } + /** * @see CookieResultMatchers.attribute * @since 6.0.8 diff --git a/spring-test/src/main/resources/META-INF/spring.factories b/spring-test/src/main/resources/META-INF/spring.factories index 2b9e4e3c116f..344b3ecb2074 100644 --- a/spring-test/src/main/resources/META-INF/spring.factories +++ b/spring-test/src/main/resources/META-INF/spring.factories @@ -4,15 +4,19 @@ org.springframework.test.context.TestExecutionListener = \ org.springframework.test.context.web.ServletTestExecutionListener,\ org.springframework.test.context.support.DirtiesContextBeforeModesTestExecutionListener,\ org.springframework.test.context.event.ApplicationEventsTestExecutionListener,\ + org.springframework.test.context.bean.override.BeanOverrideTestExecutionListener,\ org.springframework.test.context.support.DependencyInjectionTestExecutionListener,\ org.springframework.test.context.observation.MicrometerObservationRegistryTestExecutionListener,\ org.springframework.test.context.support.DirtiesContextTestExecutionListener,\ + org.springframework.test.context.support.CommonCachesTestExecutionListener,\ org.springframework.test.context.transaction.TransactionalTestExecutionListener,\ org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener,\ - org.springframework.test.context.event.EventPublishingTestExecutionListener + org.springframework.test.context.event.EventPublishingTestExecutionListener,\ + org.springframework.test.context.bean.override.mockito.MockitoResetTestExecutionListener # Default ContextCustomizerFactory implementations for the Spring TestContext Framework # org.springframework.test.context.ContextCustomizerFactory = \ + org.springframework.test.context.bean.override.BeanOverrideContextCustomizerFactory,\ org.springframework.test.context.web.socket.MockServerContainerContextCustomizerFactory,\ org.springframework.test.context.support.DynamicPropertiesContextCustomizerFactory diff --git a/spring-test/src/main/resources/mockito-extensions/org.mockito.plugins.MockResolver b/spring-test/src/main/resources/mockito-extensions/org.mockito.plugins.MockResolver new file mode 100644 index 000000000000..d7625958da22 --- /dev/null +++ b/spring-test/src/main/resources/mockito-extensions/org.mockito.plugins.MockResolver @@ -0,0 +1 @@ +org.springframework.test.context.bean.override.mockito.SpringMockResolver diff --git a/spring-test/src/test/java/org/springframework/mock/web/MockCookieTests.java b/spring-test/src/test/java/org/springframework/mock/web/MockCookieTests.java index 6a55669caf7c..e078b3809405 100644 --- a/spring-test/src/test/java/org/springframework/mock/web/MockCookieTests.java +++ b/spring-test/src/test/java/org/springframework/mock/web/MockCookieTests.java @@ -46,6 +46,7 @@ void constructCookie() { assertThat(cookie.getMaxAge()).isEqualTo(-1); assertThat(cookie.getPath()).isNull(); assertThat(cookie.isHttpOnly()).isFalse(); + assertThat(cookie.isPartitioned()).isFalse(); assertThat(cookie.getSecure()).isFalse(); assertThat(cookie.getSameSite()).isNull(); } @@ -71,7 +72,7 @@ void parseHeaderWithoutAttributes() { @Test void parseHeaderWithAttributes() { MockCookie cookie = MockCookie.parse("SESSION=123; Domain=example.com; Max-Age=60; " + - "Expires=Tue, 8 Oct 2019 19:50:00 GMT; Path=/; Secure; HttpOnly; SameSite=Lax"); + "Expires=Tue, 8 Oct 2019 19:50:00 GMT; Path=/; Secure; HttpOnly; Partitioned; SameSite=Lax"); assertCookie(cookie, "SESSION", "123"); assertThat(cookie.getDomain()).isEqualTo("example.com"); @@ -79,6 +80,7 @@ void parseHeaderWithAttributes() { assertThat(cookie.getPath()).isEqualTo("/"); assertThat(cookie.getSecure()).isTrue(); assertThat(cookie.isHttpOnly()).isTrue(); + assertThat(cookie.isPartitioned()).isTrue(); assertThat(cookie.getExpires()).isEqualTo(ZonedDateTime.parse("Tue, 8 Oct 2019 19:50:00 GMT", DateTimeFormatter.RFC_1123_DATE_TIME)); assertThat(cookie.getSameSite()).isEqualTo("Lax"); @@ -203,4 +205,14 @@ void setInvalidAttributeExpiresShouldThrow() { assertThatThrownBy(() -> cookie.setAttribute("expires", "12345")).isInstanceOf(DateTimeParseException.class); } + @Test + void setPartitioned() { + MockCookie cookie = new MockCookie("SESSION", "123"); + assertThat(cookie.isPartitioned()).isFalse(); + cookie.setPartitioned(true); + assertThat(cookie.isPartitioned()).isTrue(); + cookie.setPartitioned(false); + assertThat(cookie.isPartitioned()).isFalse(); + } + } 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 2dd438f31bbe..006c68745f88 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 @@ -676,7 +676,7 @@ void shouldRejectAsyncStartsIfUnsupported() { void startAsyncShouldUpdateRequestState() { assertThat(request.isAsyncStarted()).isFalse(); request.setAsyncSupported(true); - AsyncContext asyncContext = request.startAsync(); + request.startAsync(); assertThat(request.isAsyncStarted()).isTrue(); } 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 6d5c92007d13..7c066fef0bd3 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 @@ -30,6 +30,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.MediaType; import org.springframework.web.util.WebUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -201,7 +202,7 @@ void setCharacterEncodingNull() { response.setCharacterEncoding("UTF-8"); assertThat(response.getContentType()).isEqualTo("test/plain;charset=UTF-8"); assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo("test/plain;charset=UTF-8"); - response.setCharacterEncoding(null); + response.setCharacterEncoding((String) null); assertThat(response.getContentType()).isEqualTo("test/plain"); assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo("test/plain"); assertThat(response.getCharacterEncoding()).isEqualTo(WebUtils.DEFAULT_CHARACTER_ENCODING); @@ -274,12 +275,13 @@ void cookies() { cookie.setMaxAge(0); cookie.setSecure(true); cookie.setHttpOnly(true); + cookie.setAttribute("Partitioned", ""); response.addCookie(cookie); assertThat(response.getHeader(SET_COOKIE)).isEqualTo(("foo=bar; Path=/path; Domain=example.com; " + "Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; " + - "Secure; HttpOnly")); + "Secure; HttpOnly; Partitioned")); } @Test @@ -619,4 +621,12 @@ void resetResetsCharset() { assertThat(contentTypeHeader).isEqualTo("text/plain"); } + @Test // gh-33019 + void contentAsStringEncodingWithJson() throws IOException { + String content = "{\"name\": \"Jürgen\"}"; + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(content); + assertThat(response.getContentAsString()).isEqualTo(content); + } + } diff --git a/spring-test/src/test/java/org/springframework/test/context/BootstrapUtilsTests.java b/spring-test/src/test/java/org/springframework/test/context/BootstrapUtilsTests.java index 2de73a634a0a..1ece9eabb12c 100644 --- a/spring-test/src/test/java/org/springframework/test/context/BootstrapUtilsTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/BootstrapUtilsTests.java @@ -40,8 +40,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; -import static org.junit.jupiter.api.Named.named; -import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.junit.jupiter.params.provider.Arguments.argumentSet; import static org.mockito.Mockito.mock; import static org.springframework.test.context.BootstrapUtils.resolveTestContextBootstrapper; import static org.springframework.test.context.NestedTestConfiguration.EnclosingConfiguration.INHERIT; @@ -105,7 +104,7 @@ void resolveTestContextBootstrapperWithDuplicatingMetaBootstrapWithAnnotations() /** * @since 5.3 */ - @ParameterizedTest(name = "[{index}] {0}") + @ParameterizedTest @MethodSource void resolveTestContextBootstrapperInEnclosingClassHierarchy(Class testClass, Class expectedBootstrapper) { assertBootstrapper(testClass, expectedBootstrapper); @@ -130,7 +129,7 @@ static Stream resolveTestContextBootstrapperInEnclosingClassHierarchy } private static Arguments args(Class testClass, Class expectedBootstrapper) { - return arguments(named(testClass.getSimpleName(), testClass), expectedBootstrapper); + return argumentSet(testClass.getSimpleName(), testClass, expectedBootstrapper); } /** diff --git a/spring-test/src/test/java/org/springframework/test/context/DynamicPropertiesTestSuite.java b/spring-test/src/test/java/org/springframework/test/context/DynamicPropertiesTestSuite.java new file mode 100644 index 000000000000..a6fbe771c30c --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/DynamicPropertiesTestSuite.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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; + +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; + +/** + * JUnit Platform based test suite for tests that involve the Spring TestContext + * Framework and dynamic properties. + * + *

    This suite is only intended to be used manually within an IDE. + * + *

    Logging Configuration

    + * + *

    In order for our log4j2 configuration to be used in an IDE, you must + * set the following system property before running any tests — for + * example, in Run Configurations in Eclipse. + * + *

    + * -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager
    + * 
    + * + * @author Sam Brannen + * @since 6.2 + */ +@Suite +@SelectClasses( + value = { + DynamicPropertyRegistrarIntegrationTests.class, + DynamicPropertySourceIntegrationTests.class + }, + names = { + "org.springframework.test.context.junit.jupiter.nested.DynamicPropertySourceNestedTests", + "org.springframework.test.context.support.DefaultTestPropertySourcesIntegrationTests", + "org.springframework.test.context.support.DynamicPropertiesContextCustomizerFactoryTests", + "org.springframework.test.context.support.DynamicValuesPropertySourceTests" + } +) +class DynamicPropertiesTestSuite { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/DynamicPropertyRegistrarIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/DynamicPropertyRegistrarIntegrationTests.java new file mode 100644 index 000000000000..cd29d2c198dc --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/DynamicPropertyRegistrarIntegrationTests.java @@ -0,0 +1,196 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatRuntimeException; + +/** + * Integration tests for {@link DynamicPropertyRegistrar} bean support. + * + * @author Sam Brannen + * @since 6.2 + * @see DynamicPropertySourceIntegrationTests + */ +@SpringJUnitConfig +@TestPropertySource(properties = "api.url.1: https://example.com/test") +class DynamicPropertyRegistrarIntegrationTests { + + private static final String API_URL_1 = "api.url.1"; + private static final String API_URL_2 = "api.url.2"; + + + @Test + void customDynamicPropertyRegistryCanExistInApplicationContext( + @Autowired DynamicPropertyRegistry dynamicPropertyRegistry) { + + assertThatRuntimeException() + .isThrownBy(() -> dynamicPropertyRegistry.add("test", () -> "test")) + .withMessage("Boom!"); + } + + @Test + void dynamicPropertySourceOverridesTestPropertySource(@Autowired ConfigurableEnvironment env) { + assertApiUrlIsDynamic1(env.getProperty(API_URL_1)); + + MutablePropertySources propertySources = env.getPropertySources(); + assertThat(propertySources.size()).isGreaterThanOrEqualTo(4); + assertThat(propertySources.contains("Inlined Test Properties")).isTrue(); + assertThat(propertySources.contains("Dynamic Test Properties")).isTrue(); + assertThat(propertySources.get("Inlined Test Properties").getProperty(API_URL_1)).isEqualTo("https://example.com/test"); + assertThat(propertySources.get("Dynamic Test Properties").getProperty(API_URL_1)).isEqualTo("https://example.com/dynamic/1"); + assertThat(propertySources.get("Dynamic Test Properties").getProperty(API_URL_2)).isEqualTo("https://example.com/dynamic/2"); + } + + @Test + void testReceivesDynamicProperties(@Value("${api.url.1}") String apiUrl1, @Value("${api.url.2}") String apiUrl2) { + assertApiUrlIsDynamic1(apiUrl1); + assertApiUrlIsDynamic2(apiUrl2); + } + + @Test + void environmentInjectedServiceCanRetrieveDynamicProperty(@Autowired EnvironmentInjectedService service) { + assertApiUrlIsDynamic1(service); + } + + @Test + void constructorInjectedServiceReceivesDynamicProperty(@Autowired ConstructorInjectedService service) { + assertApiUrlIsDynamic1(service); + } + + @Test + void setterInjectedServiceReceivesDynamicProperty(@Autowired SetterInjectedService service) { + assertApiUrlIsDynamic1(service); + } + + + private static void assertApiUrlIsDynamic1(ApiUrlClient service) { + assertApiUrlIsDynamic1(service.getApiUrl()); + } + + private static void assertApiUrlIsDynamic1(String apiUrl) { + assertThat(apiUrl).isEqualTo("https://example.com/dynamic/1"); + } + + private static void assertApiUrlIsDynamic2(String apiUrl) { + assertThat(apiUrl).isEqualTo("https://example.com/dynamic/2"); + } + + + @Configuration + @Import({ EnvironmentInjectedService.class, ConstructorInjectedService.class, SetterInjectedService.class }) + static class Config { + + @Bean + ApiServer apiServer() { + return new ApiServer(); + } + + // Accepting ApiServer as a method argument ensures that the apiServer + // bean will be instantiated before any other singleton beans in the + // context which further ensures that the dynamic "api.url" property is + // available to all standard singleton beans. + @Bean + DynamicPropertyRegistrar apiPropertiesRegistrar1(ApiServer apiServer) { + return registry -> registry.add(API_URL_1, () -> apiServer.getUrl() + "/1"); + } + + @Bean + DynamicPropertyRegistrar apiPropertiesRegistrar2(ApiServer apiServer) { + return registry -> registry.add(API_URL_2, () -> apiServer.getUrl() + "/2"); + } + + @Bean + DynamicPropertyRegistry dynamicPropertyRegistry() { + return (name, valueSupplier) -> { + throw new RuntimeException("Boom!"); + }; + } + + } + + interface ApiUrlClient { + + String getApiUrl(); + } + + static class EnvironmentInjectedService implements ApiUrlClient { + + private final Environment env; + + + EnvironmentInjectedService(Environment env) { + this.env = env; + } + + @Override + public String getApiUrl() { + return this.env.getProperty(API_URL_1); + } + } + + static class ConstructorInjectedService implements ApiUrlClient { + + private final String apiUrl; + + + ConstructorInjectedService(@Value("${api.url.1}") String apiUrl) { + this.apiUrl = apiUrl; + } + + @Override + public String getApiUrl() { + return this.apiUrl; + } + } + + static class SetterInjectedService implements ApiUrlClient { + + private String apiUrl; + + + @Autowired + void setApiUrl(@Value("${api.url.1}") String apiUrl) { + this.apiUrl = apiUrl; + } + + @Override + public String getApiUrl() { + return this.apiUrl; + } + } + + static class ApiServer { + + String getUrl() { + return "https://example.com/dynamic"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/DynamicPropertySourceIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/DynamicPropertySourceIntegrationTests.java index 550de54679fa..46bba1db14f9 100644 --- a/spring-test/src/test/java/org/springframework/test/context/DynamicPropertySourceIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/DynamicPropertySourceIntegrationTests.java @@ -23,6 +23,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.core.env.ConfigurableEnvironment; @@ -30,13 +31,16 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatRuntimeException; import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; /** - * Integration tests for {@link DynamicPropertySource @DynamicPropertySource}. + * Integration tests for {@link DynamicPropertySource @DynamicPropertySource} and + * {@link DynamicPropertyRegistrar} bean support. * * @author Phillip Webb * @author Sam Brannen + * @see DynamicPropertyRegistrarIntegrationTests */ @SpringJUnitConfig @TestPropertySource(properties = "test.container.ip: test") @@ -45,6 +49,7 @@ class DynamicPropertySourceIntegrationTests { private static final String TEST_CONTAINER_IP = "test.container.ip"; + private static final String MAGIC_WORD = "magic.word"; static { System.setProperty(TEST_CONTAINER_IP, "system"); @@ -53,8 +58,12 @@ class DynamicPropertySourceIntegrationTests { static final DemoContainer container = new DemoContainer(); @DynamicPropertySource - static void containerProperties(DynamicPropertyRegistry registry) { + static void containerPropertiesIpAddress(DynamicPropertyRegistry registry) { registry.add(TEST_CONTAINER_IP, container::getIpAddress); + } + + @DynamicPropertySource + static void containerPropertiesPort(DynamicPropertyRegistry registry) { registry.add("test.container.port", container::getPort); } @@ -64,6 +73,16 @@ void clearSystemProperty() { System.clearProperty(TEST_CONTAINER_IP); } + @Test + @DisplayName("A custom DynamicPropertyRegistry bean can exist in the ApplicationContext") + void customDynamicPropertyRegistryCanExistInApplicationContext( + @Autowired DynamicPropertyRegistry dynamicPropertyRegistry) { + + assertThatRuntimeException() + .isThrownBy(() -> dynamicPropertyRegistry.add("test", () -> "test")) + .withMessage("Boom!"); + } + @Test @DisplayName("@DynamicPropertySource overrides @TestPropertySource and JVM system property") void dynamicPropertySourceOverridesTestPropertySourceAndSystemProperty(@Autowired ConfigurableEnvironment env) { @@ -73,9 +92,11 @@ void dynamicPropertySourceOverridesTestPropertySourceAndSystemProperty(@Autowire assertThat(propertySources.contains("Inlined Test Properties")).isTrue(); assertThat(propertySources.contains("systemProperties")).isTrue(); assertThat(propertySources.get("Dynamic Test Properties").getProperty(TEST_CONTAINER_IP)).isEqualTo("127.0.0.1"); + assertThat(propertySources.get("Dynamic Test Properties").getProperty(MAGIC_WORD)).isEqualTo("enigma"); assertThat(propertySources.get("Inlined Test Properties").getProperty(TEST_CONTAINER_IP)).isEqualTo("test"); assertThat(propertySources.get("systemProperties").getProperty(TEST_CONTAINER_IP)).isEqualTo("system"); assertThat(env.getProperty(TEST_CONTAINER_IP)).isEqualTo("127.0.0.1"); + assertThat(env.getProperty(MAGIC_WORD)).isEqualTo("enigma"); } @Test @@ -89,6 +110,19 @@ void serviceHasInjectedValues(@Autowired Service service) { @Configuration @Import(Service.class) static class Config { + + @Bean + DynamicPropertyRegistrar magicPropertiesRegistrar() { + return registry -> registry.add(MAGIC_WORD, () -> "enigma"); + } + + @Bean + DynamicPropertyRegistry dynamicPropertyRegistry() { + return (name, valueSupplier) -> { + throw new RuntimeException("Boom!"); + }; + } + } static class Service { diff --git a/spring-test/src/test/java/org/springframework/test/context/TestExecutionListenersTests.java b/spring-test/src/test/java/org/springframework/test/context/TestExecutionListenersTests.java index 595208e0596e..001dfd81211e 100644 --- a/spring-test/src/test/java/org/springframework/test/context/TestExecutionListenersTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/TestExecutionListenersTests.java @@ -25,10 +25,13 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.AliasFor; import org.springframework.core.annotation.AnnotationConfigurationException; +import org.springframework.test.context.bean.override.BeanOverrideTestExecutionListener; +import org.springframework.test.context.bean.override.mockito.MockitoResetTestExecutionListener; import org.springframework.test.context.event.ApplicationEventsTestExecutionListener; import org.springframework.test.context.event.EventPublishingTestExecutionListener; import org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener; import org.springframework.test.context.support.AbstractTestExecutionListener; +import org.springframework.test.context.support.CommonCachesTestExecutionListener; import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; import org.springframework.test.context.support.DirtiesContextBeforeModesTestExecutionListener; import org.springframework.test.context.support.DirtiesContextTestExecutionListener; @@ -65,12 +68,15 @@ void defaultListeners() { List> expected = asList(ServletTestExecutionListener.class,// DirtiesContextBeforeModesTestExecutionListener.class,// ApplicationEventsTestExecutionListener.class,// + BeanOverrideTestExecutionListener.class,// DependencyInjectionTestExecutionListener.class,// micrometerListenerClass,// DirtiesContextTestExecutionListener.class,// + CommonCachesTestExecutionListener.class, // TransactionalTestExecutionListener.class,// SqlScriptsTestExecutionListener.class,// - EventPublishingTestExecutionListener.class + EventPublishingTestExecutionListener.class,// + MockitoResetTestExecutionListener.class// ); assertRegisteredListeners(DefaultListenersTestCase.class, expected); } @@ -84,12 +90,15 @@ void defaultListenersMergedWithCustomListenerPrepended() { ServletTestExecutionListener.class,// DirtiesContextBeforeModesTestExecutionListener.class,// ApplicationEventsTestExecutionListener.class,// + BeanOverrideTestExecutionListener.class,// DependencyInjectionTestExecutionListener.class,// micrometerListenerClass,// DirtiesContextTestExecutionListener.class,// + CommonCachesTestExecutionListener.class, // TransactionalTestExecutionListener.class,// SqlScriptsTestExecutionListener.class,// - EventPublishingTestExecutionListener.class + EventPublishingTestExecutionListener.class,// + MockitoResetTestExecutionListener.class// ); assertRegisteredListeners(MergedDefaultListenersWithCustomListenerPrependedTestCase.class, expected); } @@ -102,12 +111,15 @@ void defaultListenersMergedWithCustomListenerAppended() { List> expected = asList(ServletTestExecutionListener.class,// DirtiesContextBeforeModesTestExecutionListener.class,// ApplicationEventsTestExecutionListener.class,// + BeanOverrideTestExecutionListener.class,// DependencyInjectionTestExecutionListener.class,// micrometerListenerClass,// DirtiesContextTestExecutionListener.class,// + CommonCachesTestExecutionListener.class, // TransactionalTestExecutionListener.class, SqlScriptsTestExecutionListener.class,// EventPublishingTestExecutionListener.class,// + MockitoResetTestExecutionListener.class,// BazTestExecutionListener.class ); assertRegisteredListeners(MergedDefaultListenersWithCustomListenerAppendedTestCase.class, expected); @@ -121,13 +133,16 @@ void defaultListenersMergedWithCustomListenerInserted() { List> expected = asList(ServletTestExecutionListener.class,// DirtiesContextBeforeModesTestExecutionListener.class,// ApplicationEventsTestExecutionListener.class,// + BeanOverrideTestExecutionListener.class,// DependencyInjectionTestExecutionListener.class,// BarTestExecutionListener.class,// micrometerListenerClass,// DirtiesContextTestExecutionListener.class,// + CommonCachesTestExecutionListener.class, // TransactionalTestExecutionListener.class,// SqlScriptsTestExecutionListener.class,// - EventPublishingTestExecutionListener.class + EventPublishingTestExecutionListener.class,// + MockitoResetTestExecutionListener.class// ); assertRegisteredListeners(MergedDefaultListenersWithCustomListenerInsertedTestCase.class, expected); } diff --git a/spring-test/src/test/java/org/springframework/test/context/aot/AbstractAotTests.java b/spring-test/src/test/java/org/springframework/test/context/aot/AbstractAotTests.java index a18710b975b3..31a8ef5d8a12 100644 --- a/spring-test/src/test/java/org/springframework/test/context/aot/AbstractAotTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/aot/AbstractAotTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -40,6 +40,7 @@ abstract class AbstractAotTests { "org/springframework/test/context/aot/samples/basic/BasicSpringJupiterImportedConfigTests__TestContext001_BeanDefinitions.java", "org/springframework/test/context/aot/samples/basic/BasicSpringJupiterImportedConfigTests__TestContext001_BeanFactoryRegistrations.java", "org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext001_BeanDefinitions.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext001_BeanDefinitions.java", // BasicSpringJupiterSharedConfigTests "org/springframework/context/event/DefaultEventListenerFactory__TestContext002_BeanDefinitions.java", "org/springframework/context/event/EventListenerMethodProcessor__TestContext002_BeanDefinitions.java", @@ -50,6 +51,7 @@ abstract class AbstractAotTests { "org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext002_BeanDefinitions.java", "org/springframework/test/context/aot/samples/management/ManagementConfiguration__TestContext002_BeanDefinitions.java", "org/springframework/test/context/aot/samples/management/ManagementMessageService__TestContext002_ManagementBeanDefinitions.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext002_BeanDefinitions.java", // BasicSpringJupiterTests -- not generated b/c already generated for BasicSpringJupiterSharedConfigTests. // BasicSpringJupiterTests.NestedTests "org/springframework/context/event/DefaultEventListenerFactory__TestContext003_BeanDefinitions.java", @@ -61,24 +63,28 @@ abstract class AbstractAotTests { "org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext003_BeanDefinitions.java", "org/springframework/test/context/aot/samples/management/ManagementConfiguration__TestContext003_BeanDefinitions.java", "org/springframework/test/context/aot/samples/management/ManagementMessageService__TestContext003_ManagementBeanDefinitions.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext003_BeanDefinitions.java", // BasicSpringTestNGTests "org/springframework/context/event/DefaultEventListenerFactory__TestContext004_BeanDefinitions.java", "org/springframework/context/event/EventListenerMethodProcessor__TestContext004_BeanDefinitions.java", "org/springframework/test/context/aot/samples/basic/BasicSpringTestNGTests__TestContext004_ApplicationContextInitializer.java", "org/springframework/test/context/aot/samples/basic/BasicSpringTestNGTests__TestContext004_BeanFactoryRegistrations.java", "org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext004_BeanDefinitions.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext004_BeanDefinitions.java", // BasicSpringVintageTests "org/springframework/context/event/DefaultEventListenerFactory__TestContext005_BeanDefinitions.java", "org/springframework/context/event/EventListenerMethodProcessor__TestContext005_BeanDefinitions.java", "org/springframework/test/context/aot/samples/basic/BasicSpringVintageTests__TestContext005_ApplicationContextInitializer.java", "org/springframework/test/context/aot/samples/basic/BasicSpringVintageTests__TestContext005_BeanFactoryRegistrations.java", "org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext005_BeanDefinitions.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext005_BeanDefinitions.java", // DisabledInAotRuntimeMethodLevelTests "org/springframework/context/event/DefaultEventListenerFactory__TestContext006_BeanDefinitions.java", "org/springframework/context/event/EventListenerMethodProcessor__TestContext006_BeanDefinitions.java", "org/springframework/test/context/aot/samples/basic/DisabledInAotRuntimeMethodLevelTests__TestContext006_ApplicationContextInitializer.java", "org/springframework/test/context/aot/samples/basic/DisabledInAotRuntimeMethodLevelTests__TestContext006_BeanDefinitions.java", - "org/springframework/test/context/aot/samples/basic/DisabledInAotRuntimeMethodLevelTests__TestContext006_BeanFactoryRegistrations.java" + "org/springframework/test/context/aot/samples/basic/DisabledInAotRuntimeMethodLevelTests__TestContext006_BeanFactoryRegistrations.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext006_BeanDefinitions.java" }; Stream> scan() { diff --git a/spring-test/src/test/java/org/springframework/test/context/aot/AotIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/aot/AotIntegrationTests.java index 07637c03fea6..e5fa0317f832 100644 --- a/spring-test/src/test/java/org/springframework/test/context/aot/AotIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/aot/AotIntegrationTests.java @@ -42,6 +42,7 @@ import org.springframework.aot.generate.InMemoryGeneratedFiles; import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.test.generate.CompilerFiles; +import org.springframework.context.aot.AbstractAotProcessor; import org.springframework.core.test.tools.CompileWithForkedClassLoader; import org.springframework.core.test.tools.TestCompiler; import org.springframework.test.context.aot.samples.basic.BasicSpringJupiterImportedConfigTests; @@ -96,7 +97,16 @@ void endToEndTests() { // AOT BUILD-TIME: PROCESSING InMemoryGeneratedFiles generatedFiles = new InMemoryGeneratedFiles(); TestContextAotGenerator generator = new TestContextAotGenerator(generatedFiles, new RuntimeHints()); - generator.processAheadOfTime(testClasses); + try { + // Emulate AbstractAotProcessor.process(). + System.setProperty(AbstractAotProcessor.AOT_PROCESSING, "true"); + + generator.processAheadOfTime(testClasses); + } + finally { + // Emulate AbstractAotProcessor.process(). + System.clearProperty(AbstractAotProcessor.AOT_PROCESSING); + } List sourceFiles = generatedFiles.getGeneratedFiles(Kind.SOURCE).keySet().stream().toList(); assertThat(sourceFiles).containsExactlyInAnyOrder(expectedSourceFilesForBasicSpringTests); @@ -137,16 +147,29 @@ void endToEndTestsForEntireSpringTestModule() { .filter(clazz -> clazz.getSimpleName().endsWith("Tests")) // TestNG EJB tests use @PersistenceContext which is not yet supported in tests in AOT mode. .filter(clazz -> !clazz.getPackageName().contains("testng.transaction.ejb")) + // Uncomment the following to disable Bean Override tests since they are not yet supported in AOT mode. + // .filter(clazz -> !clazz.getPackageName().contains("test.context.bean.override")) .toList(); // Optionally set failOnError flag to true to halt processing at the first failure. runEndToEndTests(testClasses, false); } + @Test + void endToEndTestsForBeanOverrides() { + List> testClasses = createTestClassScanner() + .scan("org.springframework.test.context.bean.override") + .filter(clazz -> clazz.getSimpleName().endsWith("Tests")) + .toList(); + runEndToEndTests(testClasses, true); + } + @Disabled("Comment out to run selected integration tests in AOT mode") @Test void endToEndTestsForSelectedTestClasses() { List> testClasses = List.of( + org.springframework.test.context.bean.override.easymock.EasyMockBeanIntegrationTests.class, + org.springframework.test.context.bean.override.mockito.MockitoBeanForByNameLookupIntegrationTests.class, org.springframework.test.context.junit4.SpringJUnit4ClassRunnerAppCtxTests.class, org.springframework.test.context.junit4.ParameterizedDependencyInjectionTests.class ); @@ -155,10 +178,20 @@ void endToEndTestsForSelectedTestClasses() { } private void runEndToEndTests(List> testClasses, boolean failOnError) { - // AOT BUILD-TIME: PROCESSING InMemoryGeneratedFiles generatedFiles = new InMemoryGeneratedFiles(); TestContextAotGenerator generator = new TestContextAotGenerator(generatedFiles, new RuntimeHints(), failOnError); - generator.processAheadOfTime(testClasses.stream()); + + // AOT BUILD-TIME: PROCESSING + try { + // Emulate AbstractAotProcessor.process(). + System.setProperty(AbstractAotProcessor.AOT_PROCESSING, "true"); + + generator.processAheadOfTime(testClasses.stream()); + } + finally { + // Emulate AbstractAotProcessor.process(). + System.clearProperty(AbstractAotProcessor.AOT_PROCESSING); + } // AOT BUILD-TIME: COMPILATION TestCompiler.forSystem().with(CompilerFiles.from(generatedFiles)) diff --git a/spring-test/src/test/java/org/springframework/test/context/aot/DisabledInAotModeTests.java b/spring-test/src/test/java/org/springframework/test/context/aot/DisabledInAotModeTests.java new file mode 100644 index 000000000000..326b53d5ee0a --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/aot/DisabledInAotModeTests.java @@ -0,0 +1,81 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.aot; + +import org.junit.jupiter.api.Test; +import org.junit.platform.testkit.engine.EngineTestKit; + +import org.springframework.aot.AotDetector; + +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; +import static org.junit.platform.testkit.engine.EventConditions.container; +import static org.junit.platform.testkit.engine.EventConditions.event; +import static org.junit.platform.testkit.engine.EventConditions.skippedWithReason; + +/** + * Tests for {@link DisabledInAotMode @DisabledInAotMode}. + * + * @author Sam Brannen + * @since 6.2 + */ +class DisabledInAotModeTests { + + @Test + void defaultDisabledReason() { + runTestsInAotMode(DefaultReasonTestCase.class, "Disabled in Spring AOT mode"); + } + + @Test + void customDisabledReason() { + runTestsInAotMode(CustomReasonTestCase.class, "Disabled in Spring AOT mode ==> @ContextHierarchy is not supported in AOT"); + } + + + private static void runTestsInAotMode(Class testClass, String expectedReason) { + try { + System.setProperty(AotDetector.AOT_ENABLED, "true"); + + EngineTestKit.engine("junit-jupiter") + .selectors(selectClass(testClass)) + .execute() + .allEvents() + .assertThatEvents().haveExactly(1, + event(container(testClass.getSimpleName()), skippedWithReason(expectedReason))); + } + finally { + System.clearProperty(AotDetector.AOT_ENABLED); + } + } + + + @DisabledInAotMode + static class DefaultReasonTestCase { + + @Test + void test() { + } + } + + @DisabledInAotMode("@ContextHierarchy is not supported in AOT") + static class CustomReasonTestCase { + + @Test + void test() { + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/aot/TestContextAotGeneratorIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/aot/TestContextAotGeneratorIntegrationTests.java index 0fdbe04b1efb..e5b0bf2250c5 100644 --- a/spring-test/src/test/java/org/springframework/test/context/aot/TestContextAotGeneratorIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/aot/TestContextAotGeneratorIntegrationTests.java @@ -17,6 +17,7 @@ package org.springframework.test.context.aot; import java.lang.annotation.Annotation; +import java.lang.reflect.Proxy; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -24,6 +25,8 @@ import javax.sql.DataSource; +import org.assertj.core.util.Arrays; +import org.easymock.EasyMockSupport; import org.junit.jupiter.api.Test; import org.springframework.aot.AotDetector; @@ -37,6 +40,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.aot.AbstractAotProcessor; import org.springframework.core.test.tools.CompileWithForkedClassLoader; import org.springframework.core.test.tools.TestCompiler; import org.springframework.javapoet.ClassName; @@ -47,6 +51,11 @@ import org.springframework.test.context.aot.samples.basic.BasicSpringJupiterTests; import org.springframework.test.context.aot.samples.basic.BasicSpringTestNGTests; import org.springframework.test.context.aot.samples.basic.BasicSpringVintageTests; +import org.springframework.test.context.aot.samples.bean.override.EasyMockBeanJupiterTests; +import org.springframework.test.context.aot.samples.bean.override.GreetingServiceFactory; +import org.springframework.test.context.aot.samples.bean.override.MockitoBeanJupiterTests; +import org.springframework.test.context.aot.samples.bean.override.TestBeanJupiterTests; +import org.springframework.test.context.aot.samples.common.GreetingService; import org.springframework.test.context.aot.samples.common.MessageService; import org.springframework.test.context.aot.samples.jdbc.SqlScriptsSpringJupiterTests; import org.springframework.test.context.aot.samples.web.WebSpringJupiterTests; @@ -67,8 +76,10 @@ import static org.springframework.aot.hint.MemberCategory.INVOKE_DECLARED_METHODS; import static org.springframework.aot.hint.MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS; import static org.springframework.aot.hint.MemberCategory.INVOKE_PUBLIC_METHODS; +import static org.springframework.aot.hint.predicate.RuntimeHintsPredicates.proxies; import static org.springframework.aot.hint.predicate.RuntimeHintsPredicates.reflection; import static org.springframework.aot.hint.predicate.RuntimeHintsPredicates.resource; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -97,6 +108,9 @@ void endToEndTests() { BasicSpringJupiterTests.NestedTests.class, BasicSpringTestNGTests.class, BasicSpringVintageTests.class, + EasyMockBeanJupiterTests.class, + MockitoBeanJupiterTests.class, + TestBeanJupiterTests.class, SqlScriptsSpringJupiterTests.class, XmlSpringJupiterTests.class, WebSpringJupiterTests.class); @@ -104,7 +118,16 @@ void endToEndTests() { InMemoryGeneratedFiles generatedFiles = new InMemoryGeneratedFiles(); TestContextAotGenerator generator = new TestContextAotGenerator(generatedFiles); - generator.processAheadOfTime(testClasses.stream().sorted(comparing(Class::getName))); + try { + // Emulate AbstractAotProcessor.process(). + System.setProperty(AbstractAotProcessor.AOT_PROCESSING, "true"); + + generator.processAheadOfTime(testClasses.stream().sorted(comparing(Class::getName))); + } + finally { + // Emulate AbstractAotProcessor.process(). + System.clearProperty(AbstractAotProcessor.AOT_PROCESSING); + } assertRuntimeHints(generator.getRuntimeHints()); @@ -142,6 +165,15 @@ void endToEndTests() { else if (testClass.getPackageName().contains("jdbc")) { assertContextForJdbcTests(context); } + else if (testClass.equals(TestBeanJupiterTests.class)) { + assertContextForTestBeanOverrideTests(context); + } + else if (testClass.equals(EasyMockBeanJupiterTests.class)) { + assertContextForEasyMockBeanOverrideTests(context); + } + else if (testClass.equals(MockitoBeanJupiterTests.class)) { + assertContextForMockitoBeanOverrideTests(context); + } else { assertContextForBasicTests(context); } @@ -186,9 +218,11 @@ private static void assertRuntimeHints(RuntimeHints runtimeHints) { Stream.of( // @TestExecutionListeners org.springframework.test.context.aot.samples.basic.BasicSpringJupiterTests.DummyTestExecutionListener.class, + // Auto-registered org.springframework.test.context.event.ApplicationEventsTestExecutionListener.class, org.springframework.test.context.event.EventPublishingTestExecutionListener.class, org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener.class, + org.springframework.test.context.bean.override.BeanOverrideTestExecutionListener.class, org.springframework.test.context.support.DependencyInjectionTestExecutionListener.class, org.springframework.test.context.support.DirtiesContextBeforeModesTestExecutionListener.class, org.springframework.test.context.support.DirtiesContextTestExecutionListener.class, @@ -198,6 +232,7 @@ private static void assertRuntimeHints(RuntimeHints runtimeHints) { // ContextCustomizerFactory Stream.of( + "org.springframework.test.context.bean.override.BeanOverrideContextCustomizerFactory", "org.springframework.test.context.support.DynamicPropertiesContextCustomizerFactory", "org.springframework.test.context.web.socket.MockServerContainerContextCustomizerFactory", "org.springframework.test.context.aot.samples.basic.ImportsContextCustomizerFactory" @@ -243,6 +278,25 @@ private static void assertRuntimeHints(RuntimeHints runtimeHints) { .accepts(runtimeHints); assertThat(resource().forResource("org/springframework/test/context/aot/samples/jdbc/SqlScriptsSpringJupiterTests.test.sql")) .accepts(runtimeHints); + + // @BeanOverride(value = ...) + Stream.of( + // @TestBean + "org.springframework.test.context.bean.override.convention.TestBeanOverrideProcessor", + // @MockitoBean + "org.springframework.test.context.bean.override.mockito.MockitoBeanOverrideProcessor", + // @EasyMockBean + "org.springframework.test.context.bean.override.easymock.EasyMockBeanOverrideProcessor" + ).forEach(type -> assertReflectionRegistered(runtimeHints, type, INVOKE_DECLARED_CONSTRUCTORS)); + + // @TestBean(methodName = ) + assertThat(reflection().onMethod(GreetingServiceFactory.class, "createEnigmaGreetingService")) + .accepts(runtimeHints); + + // GenericApplicationContext.preDetermineBeanTypes() should have registered proxy + // hints for the EasyMock interface-based mocks. + assertProxyRegistered(runtimeHints, GreetingService.class); + assertProxyRegistered(runtimeHints, MessageService.class); } private static void assertReflectionRegistered(RuntimeHints runtimeHints, String type) { @@ -267,6 +321,13 @@ private static void assertAnnotationRegistered(RuntimeHints runtimeHints, Class< assertReflectionRegistered(runtimeHints, annotationType, INVOKE_DECLARED_METHODS); } + private static void assertProxyRegistered(RuntimeHints runtimeHints, Class... interfaces) { + assertThat(proxies().forInterfaces(interfaces)) + .as("Proxy hint for %s", Arrays.asList(interfaces)) + .accepts(runtimeHints); + } + + @Test void processAheadOfTimeWithBasicTests() { @@ -297,6 +358,29 @@ private void assertContextForJdbcTests(ApplicationContext context) { assertThat(context.getBean(DataSource.class)).as("DataSource").isNotNull(); } + private void assertContextForTestBeanOverrideTests(ApplicationContext context) { + GreetingService greetingService = context.getBean(GreetingService.class); + assertThat(greetingService.greeting()).isEqualTo("enigma"); + } + + private void assertContextForEasyMockBeanOverrideTests(ApplicationContext context) { + GreetingService greetingService = context.getBean(GreetingService.class); + MessageService messageService = context.getBean(MessageService.class); + + assertThat(EasyMockSupport.isAMock(greetingService)).as("EasyMock mock").isTrue(); + assertThat(EasyMockSupport.isAMock(messageService)).as("EasyMock mock").isTrue(); + assertThat(Proxy.isProxyClass(greetingService.getClass())).as("JDK proxy").isTrue(); + assertThat(Proxy.isProxyClass(messageService.getClass())).as("JDK proxy").isTrue(); + } + + private void assertContextForMockitoBeanOverrideTests(ApplicationContext context) { + GreetingService greetingService = context.getBean(GreetingService.class); + MessageService messageService = context.getBean(MessageService.class); + + assertIsMock(greetingService, "greetingService"); + assertIsMock(messageService, "messageService"); + } + private void assertContextForWebTests(WebApplicationContext wac) throws Exception { assertThat(wac.getEnvironment().getProperty("test.engine")).as("Environment").isNotNull(); @@ -364,10 +448,19 @@ private List processAheadOfTime(TestContextAotGenerator generator, Set< testClasses.forEach(testClass -> { DefaultGenerationContext generationContext = generator.createGenerationContext(testClass); MergedContextConfiguration mergedConfig = buildMergedContextConfiguration(testClass); - ClassName className = generator.processAheadOfTime(mergedConfig, generationContext); - assertThat(className).isNotNull(); - mappings.add(new Mapping(mergedConfig, className)); - generationContext.writeGeneratedContent(); + try { + // Emulate AbstractAotProcessor.process(). + System.setProperty(AbstractAotProcessor.AOT_PROCESSING, "true"); + + ClassName className = generator.processAheadOfTime(mergedConfig, generationContext); + assertThat(className).isNotNull(); + mappings.add(new Mapping(mergedConfig, className)); + generationContext.writeGeneratedContent(); + } + finally { + // Emulate AbstractAotProcessor.process(). + System.clearProperty(AbstractAotProcessor.AOT_PROCESSING); + } }); return mappings; } @@ -395,6 +488,7 @@ record Mapping(MergedContextConfiguration mergedConfig, ClassName className) { "org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext001_BeanDefinitions.java", "org/springframework/test/context/aot/samples/management/ManagementConfiguration__TestContext001_BeanDefinitions.java", "org/springframework/test/context/aot/samples/management/ManagementMessageService__TestContext001_ManagementBeanDefinitions.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext001_BeanDefinitions.java", // BasicSpringJupiterTests -- not generated b/c already generated for BasicSpringJupiterSharedConfigTests. // BasicSpringJupiterTests.NestedTests "org/springframework/context/event/DefaultEventListenerFactory__TestContext002_BeanDefinitions.java", @@ -406,38 +500,68 @@ record Mapping(MergedContextConfiguration mergedConfig, ClassName className) { "org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext002_BeanDefinitions.java", "org/springframework/test/context/aot/samples/management/ManagementConfiguration__TestContext002_BeanDefinitions.java", "org/springframework/test/context/aot/samples/management/ManagementMessageService__TestContext002_ManagementBeanDefinitions.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext002_BeanDefinitions.java", // BasicSpringTestNGTests "org/springframework/context/event/DefaultEventListenerFactory__TestContext003_BeanDefinitions.java", "org/springframework/context/event/EventListenerMethodProcessor__TestContext003_BeanDefinitions.java", "org/springframework/test/context/aot/samples/basic/BasicSpringTestNGTests__TestContext003_ApplicationContextInitializer.java", "org/springframework/test/context/aot/samples/basic/BasicSpringTestNGTests__TestContext003_BeanFactoryRegistrations.java", "org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext003_BeanDefinitions.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext003_BeanDefinitions.java", // BasicSpringVintageTests "org/springframework/context/event/DefaultEventListenerFactory__TestContext004_BeanDefinitions.java", "org/springframework/context/event/EventListenerMethodProcessor__TestContext004_BeanDefinitions.java", "org/springframework/test/context/aot/samples/basic/BasicSpringVintageTests__TestContext004_ApplicationContextInitializer.java", "org/springframework/test/context/aot/samples/basic/BasicSpringVintageTests__TestContext004_BeanFactoryRegistrations.java", "org/springframework/test/context/aot/samples/basic/BasicTestConfiguration__TestContext004_BeanDefinitions.java", - // SqlScriptsSpringJupiterTests + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext004_BeanDefinitions.java", + // EasyMockBeanJupiterTests "org/springframework/context/event/DefaultEventListenerFactory__TestContext005_BeanDefinitions.java", "org/springframework/context/event/EventListenerMethodProcessor__TestContext005_BeanDefinitions.java", - "org/springframework/test/context/aot/samples/jdbc/SqlScriptsSpringJupiterTests__TestContext005_ApplicationContextInitializer.java", - "org/springframework/test/context/aot/samples/jdbc/SqlScriptsSpringJupiterTests__TestContext005_BeanFactoryRegistrations.java", - "org/springframework/test/context/jdbc/EmptyDatabaseConfig__TestContext005_BeanDefinitions.java", - // WebSpringJupiterTests + "org/springframework/test/context/aot/samples/bean/override/EasyMockBeanJupiterTests__TestContext005_ApplicationContextInitializer.java", + "org/springframework/test/context/aot/samples/bean/override/EasyMockBeanJupiterTests__TestContext005_BeanDefinitions.java", + "org/springframework/test/context/aot/samples/bean/override/EasyMockBeanJupiterTests__TestContext005_BeanFactoryRegistrations.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext005_BeanDefinitions.java", + // MockitoBeanJupiterTests "org/springframework/context/event/DefaultEventListenerFactory__TestContext006_BeanDefinitions.java", "org/springframework/context/event/EventListenerMethodProcessor__TestContext006_BeanDefinitions.java", - "org/springframework/test/context/aot/samples/web/WebSpringJupiterTests__TestContext006_ApplicationContextInitializer.java", - "org/springframework/test/context/aot/samples/web/WebSpringJupiterTests__TestContext006_BeanFactoryRegistrations.java", - "org/springframework/test/context/aot/samples/web/WebTestConfiguration__TestContext006_BeanDefinitions.java", - "org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfiguration__TestContext006_Autowiring.java", - "org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfiguration__TestContext006_BeanDefinitions.java", - // XmlSpringJupiterTests + "org/springframework/test/context/aot/samples/bean/override/MockitoBeanJupiterTests__TestContext006_ApplicationContextInitializer.java", + "org/springframework/test/context/aot/samples/bean/override/MockitoBeanJupiterTests__TestContext006_BeanDefinitions.java", + "org/springframework/test/context/aot/samples/bean/override/MockitoBeanJupiterTests__TestContext006_BeanFactoryRegistrations.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext006_BeanDefinitions.java", + + // TestBeanJupiterTests "org/springframework/context/event/DefaultEventListenerFactory__TestContext007_BeanDefinitions.java", "org/springframework/context/event/EventListenerMethodProcessor__TestContext007_BeanDefinitions.java", - "org/springframework/test/context/aot/samples/common/DefaultMessageService__TestContext007_BeanDefinitions.java", - "org/springframework/test/context/aot/samples/xml/XmlSpringJupiterTests__TestContext007_ApplicationContextInitializer.java", - "org/springframework/test/context/aot/samples/xml/XmlSpringJupiterTests__TestContext007_BeanFactoryRegistrations.java" + "org/springframework/test/context/aot/samples/bean/override/TestBeanJupiterTests__TestContext007_ApplicationContextInitializer.java", + "org/springframework/test/context/aot/samples/bean/override/TestBeanJupiterTests__TestContext007_BeanFactoryRegistrations.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext007_BeanDefinitions.java", + + // SqlScriptsSpringJupiterTests + "org/springframework/context/event/DefaultEventListenerFactory__TestContext008_BeanDefinitions.java", + "org/springframework/context/event/EventListenerMethodProcessor__TestContext008_BeanDefinitions.java", + "org/springframework/test/context/aot/samples/jdbc/SqlScriptsSpringJupiterTests__TestContext008_ApplicationContextInitializer.java", + "org/springframework/test/context/aot/samples/jdbc/SqlScriptsSpringJupiterTests__TestContext008_BeanFactoryRegistrations.java", + "org/springframework/test/context/jdbc/EmptyDatabaseConfig__TestContext008_BeanDefinitions.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext008_BeanDefinitions.java", + + // WebSpringJupiterTests + "org/springframework/context/event/DefaultEventListenerFactory__TestContext009_BeanDefinitions.java", + "org/springframework/context/event/EventListenerMethodProcessor__TestContext009_BeanDefinitions.java", + "org/springframework/test/context/aot/samples/web/WebSpringJupiterTests__TestContext009_ApplicationContextInitializer.java", + "org/springframework/test/context/aot/samples/web/WebSpringJupiterTests__TestContext009_BeanFactoryRegistrations.java", + "org/springframework/test/context/aot/samples/web/WebTestConfiguration__TestContext009_BeanDefinitions.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext009_BeanDefinitions.java", + "org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfiguration__TestContext009_Autowiring.java", + "org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfiguration__TestContext009_BeanDefinitions.java", + + // XmlSpringJupiterTests + "org/springframework/context/event/DefaultEventListenerFactory__TestContext010_BeanDefinitions.java", + "org/springframework/context/event/EventListenerMethodProcessor__TestContext010_BeanDefinitions.java", + "org/springframework/test/context/aot/samples/common/DefaultMessageService__TestContext010_BeanDefinitions.java", + "org/springframework/test/context/aot/samples/xml/XmlSpringJupiterTests__TestContext010_ApplicationContextInitializer.java", + "org/springframework/test/context/aot/samples/xml/XmlSpringJupiterTests__TestContext010_BeanFactoryRegistrations.java", + "org/springframework/test/context/support/DynamicPropertyRegistrarBeanInitializer__TestContext010_BeanDefinitions.java" }; } diff --git a/spring-test/src/test/java/org/springframework/test/context/aot/samples/bean/override/EasyMockBeanJupiterTests.java b/spring-test/src/test/java/org/springframework/test/context/aot/samples/bean/override/EasyMockBeanJupiterTests.java new file mode 100644 index 000000000000..39d7dddd7180 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/aot/samples/bean/override/EasyMockBeanJupiterTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.aot.samples.bean.override; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.aot.samples.common.GreetingService; +import org.springframework.test.context.aot.samples.common.MessageService; +import org.springframework.test.context.bean.override.easymock.EasyMockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; + +/** + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +public class EasyMockBeanJupiterTests { + + /** + * Mock for nonexistent bean. + */ + @EasyMockBean + GreetingService greetingService; + + /** + * Mock for existing bean. + */ + @EasyMockBean + MessageService messageService; + + @BeforeEach + void configureMocks(@Autowired GreetingService greetingService, @Autowired MessageService messageService) { + expect(greetingService.greeting()).andReturn("enigma"); + expect(messageService.generateMessage()).andReturn("override"); + replay(greetingService, messageService); + } + + @Test + void test() { + assertThat(greetingService.greeting()).isEqualTo("enigma"); + assertThat(messageService.generateMessage()).isEqualTo("override"); + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + MessageService messageService() { + return () -> "prod"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/aot/samples/bean/override/GreetingServiceFactory.java b/spring-test/src/test/java/org/springframework/test/context/aot/samples/bean/override/GreetingServiceFactory.java new file mode 100644 index 000000000000..dae863f87e2f --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/aot/samples/bean/override/GreetingServiceFactory.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.aot.samples.bean.override; + +import org.springframework.test.context.aot.samples.common.GreetingService; + +/** + * @author Sam Brannen + * @since 6.2 + */ +public class GreetingServiceFactory { + + public static GreetingService createEnigmaGreetingService() { + return () -> "enigma"; + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/aot/samples/bean/override/MockitoBeanJupiterTests.java b/spring-test/src/test/java/org/springframework/test/context/aot/samples/bean/override/MockitoBeanJupiterTests.java new file mode 100644 index 000000000000..f914c84c5d6b --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/aot/samples/bean/override/MockitoBeanJupiterTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.aot.samples.bean.override; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.aot.samples.common.GreetingService; +import org.springframework.test.context.aot.samples.common.MessageService; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.when; + +/** + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +public class MockitoBeanJupiterTests { + + /** + * Mock for nonexistent bean. + */ + @MockitoBean + GreetingService greetingService; + + /** + * Mock for existing bean. + */ + @MockitoBean + MessageService messageService; + + @BeforeEach + void configureMocks(@Autowired GreetingService greetingService, @Autowired MessageService messageService) { + when(greetingService.greeting()).thenReturn("enigma"); + when(messageService.generateMessage()).thenReturn("override"); + } + + @Test + void test() { + assertThat(greetingService.greeting()).isEqualTo("enigma"); + assertThat(messageService.generateMessage()).isEqualTo("override"); + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + MessageService messageService() { + return () -> "prod"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/aot/samples/bean/override/TestBeanJupiterTests.java b/spring-test/src/test/java/org/springframework/test/context/aot/samples/bean/override/TestBeanJupiterTests.java new file mode 100644 index 000000000000..cca32117bb21 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/aot/samples/bean/override/TestBeanJupiterTests.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.aot.samples.bean.override; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.test.context.aot.samples.common.GreetingService; +import org.springframework.test.context.bean.override.convention.TestBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Sam Brannen + * @since 6.2 + */ +@ExtendWith(SpringExtension.class) +public class TestBeanJupiterTests { + + @TestBean(methodName = "org.springframework.test.context.aot.samples.bean.override.GreetingServiceFactory#createEnigmaGreetingService") + GreetingService greetingService; + + @Test + void test() { + assertThat(greetingService.greeting()).isEqualTo("enigma"); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/aot/samples/common/GreetingService.java b/spring-test/src/test/java/org/springframework/test/context/aot/samples/common/GreetingService.java new file mode 100644 index 000000000000..53e31b7ed988 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/aot/samples/common/GreetingService.java @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.aot.samples.common; + +/** + * @author Sam Brannen + * @since 6.2 + */ +@FunctionalInterface +public interface GreetingService { + + String greeting(); + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/aot/samples/web/WebSpringJupiterTests.java b/spring-test/src/test/java/org/springframework/test/context/aot/samples/web/WebSpringJupiterTests.java index 0db71791319b..746dbdbaa188 100644 --- a/spring-test/src/test/java/org/springframework/test/context/aot/samples/web/WebSpringJupiterTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/aot/samples/web/WebSpringJupiterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +64,7 @@ void controller(@Value("${test.engine}") String testEngine) throws Exception { void resources() throws Exception { this.mockMvc.perform(get("/resources/Spring.js")) .andExpectAll( - content().contentType("application/javascript"), + content().contentType("text/javascript"), content().string(containsString("Spring={};")) ); } diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessorTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessorTests.java new file mode 100644 index 000000000000..76cb72be8b15 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessorTests.java @@ -0,0 +1,582 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.lang.reflect.Field; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.BeanWrapper; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.NoUniqueBeanDefinitionException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.support.SimpleThreadScope; +import org.springframework.core.Ordered; +import org.springframework.core.ResolvableType; +import org.springframework.test.context.MergedContextConfiguration; +import org.springframework.test.context.bean.override.convention.TestBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.Assert; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link BeanOverrideBeanFactoryPostProcessor} combined with a + * {@link BeanOverrideRegistry}. + * + * @author Simon Baslé + * @author Stephane Nicoll + * @author Sam Brannen + */ +class BeanOverrideBeanFactoryPostProcessorTests { + + @Test + void beanNameWithFactoryBeanPrefixIsRejected() { + AnnotationConfigApplicationContext context = createContext(FactoryBeanPrefixTestCase.class); + + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to override bean '&messageService' for field 'FactoryBeanPrefixTestCase.messageService': \ + a FactoryBean cannot be overridden. To override the bean created by the FactoryBean, remove the \ + '&' prefix."""); + } + + @Test + void replaceBeanByNameWithMatchingBeanDefinition() { + AnnotationConfigApplicationContext context = createContext(ByNameTestCase.class); + context.registerBean("descriptionBean", String.class, () -> "Original"); + context.refresh(); + + assertThat(context.getBean("descriptionBean")).isEqualTo("overridden"); + } + + @Test + void replaceBeanByNameWithoutMatchingBeanDefinitionFails() { + AnnotationConfigApplicationContext context = createContext(ByNameTestCase.class); + + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to replace bean: there is no bean with name 'descriptionBean' \ + and type java.lang.String (as required by field 'ByNameTestCase.description'). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type)."""); + } + + @Test + void replaceBeanByNameWithMatchingBeanDefinitionAndWrongTypeFails() { + AnnotationConfigApplicationContext context = createContext(ByNameTestCase.class); + context.registerBean("descriptionBean", Integer.class, () -> -1); + + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to replace bean: there is no bean with name 'descriptionBean' \ + and type java.lang.String (as required by field 'ByNameTestCase.description'). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type)."""); + } + + @Test + void replaceBeanByNameCanOverrideBeanProducedByFactoryBeanWithClassObjectTypeAttribute() { + AnnotationConfigApplicationContext context = prepareContextWithFactoryBean(CharSequence.class); + context.refresh(); + + assertThat(context.getBean("beanToBeOverridden")).isEqualTo("overridden"); + } + + @Test + void replaceBeanByNameCanOverrideBeanProducedByFactoryBeanWithResolvableTypeObjectTypeAttribute() { + AnnotationConfigApplicationContext context = prepareContextWithFactoryBean(ResolvableType.forClass(CharSequence.class)); + context.refresh(); + + assertThat(context.getBean("beanToBeOverridden")).isEqualTo("overridden"); + } + + private AnnotationConfigApplicationContext prepareContextWithFactoryBean(Object objectTypeAttribute) { + AnnotationConfigApplicationContext context = createContext(OverrideBeanProducedByFactoryBeanTestCase.class); + // Register a TestFactoryBean that will not be overridden + context.registerBean("testFactoryBean", TestFactoryBean.class, TestFactoryBean::new); + // Register another TestFactoryBean that will be overridden + RootBeanDefinition factoryBeanDefinition = new RootBeanDefinition(TestFactoryBean.class); + factoryBeanDefinition.setAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE, objectTypeAttribute); + context.registerBeanDefinition("beanToBeOverridden", factoryBeanDefinition); + return context; + } + + @Test + void replaceBeanByTypeWithSingleMatchingBean() { + AnnotationConfigApplicationContext context = createContext(ByTypeTestCase.class); + context.registerBean("someInteger", Integer.class, () -> 1); + context.refresh(); + + assertThat(context.getBean("someInteger")).isEqualTo(42); + } + + @Test + void replaceBeanByTypeWithoutMatchingBeanFails() { + AnnotationConfigApplicationContext context = createContext(ByTypeTestCase.class); + + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to override bean: there are no beans of type java.lang.Integer \ + (as required by field 'ByTypeTestCase.counter'). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type)."""); + } + + @Test + void replaceBeanByTypeWithMultipleCandidatesAndNoQualifierFails() { + AnnotationConfigApplicationContext context = createContext(ByTypeTestCase.class); + context.registerBean("someInteger", Integer.class, () -> 1); + context.registerBean("anotherInteger", Integer.class, () -> 2); + + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to select a bean to override: found 2 beans of type java.lang.Integer \ + (as required by field 'ByTypeTestCase.counter'): %s""", + List.of("someInteger", "anotherInteger")); + } + + @Test + void replaceBeanByTypeWithMultipleCandidatesAndFieldNameAsFallbackQualifier() { + AnnotationConfigApplicationContext context = createContext(ByTypeTestCase.class); + context.registerBean("counter", Integer.class, () -> 1); + context.registerBean("someInteger", Integer.class, () -> 2); + context.refresh(); + + assertThat(context.getBean("counter")).isSameAs(42); + } + + @Test // gh-33819 + void replaceBeanByTypeWithMultipleCandidatesAndOnePrimary() { + AnnotationConfigApplicationContext context = createContext(TestBeanByTypeTestCase.class); + context.registerBean("description1", String.class, () -> "one"); + RootBeanDefinition beanDefinition2 = new RootBeanDefinition(String.class); + beanDefinition2.getConstructorArgumentValues().addIndexedArgumentValue(0, "two"); + beanDefinition2.setPrimary(true); + context.registerBeanDefinition("description2", beanDefinition2); + context.refresh(); + + assertThat(context.getBean("description1", String.class)).isEqualTo("one"); + assertThat(context.getBean("description2", String.class)).isEqualTo("overridden"); + assertThat(context.getBean(String.class)).isEqualTo("overridden"); + } + + @Test // gh-33819 + void replaceBeanByTypeWithMultipleCandidatesAndMultiplePrimaryBeansFails() { + AnnotationConfigApplicationContext context = createContext(TestBeanByTypeTestCase.class); + + RootBeanDefinition beanDefinition1 = new RootBeanDefinition(String.class); + beanDefinition1.getConstructorArgumentValues().addIndexedArgumentValue(0, "one"); + beanDefinition1.setPrimary(true); + context.registerBeanDefinition("description1", beanDefinition1); + + RootBeanDefinition beanDefinition2 = new RootBeanDefinition(String.class); + beanDefinition2.getConstructorArgumentValues().addIndexedArgumentValue(0, "two"); + beanDefinition2.setPrimary(true); + context.registerBeanDefinition("description2", beanDefinition2); + + assertThatExceptionOfType(NoUniqueBeanDefinitionException.class) + .isThrownBy(context::refresh) + .withMessage("No qualifying bean of type 'java.lang.String' available: " + + "more than one 'primary' bean found among candidates: [description1, description2]"); + } + + @Test + void createOrReplaceBeanByNameWithMatchingBeanDefinition() { + AnnotationConfigApplicationContext context = createContext(ByNameWithReplaceOrCreateStrategyTestCase.class); + context.registerBean("descriptionBean", String.class, () -> "Original"); + context.refresh(); + + assertThat(context.getBean("descriptionBean")).isEqualTo("overridden"); + } + + @Test + void createOrReplaceBeanByNameWithoutMatchingDefinitionCreatesBeanDefinition() { + AnnotationConfigApplicationContext context = createContext(ByNameWithReplaceOrCreateStrategyTestCase.class); + context.refresh(); + + assertThat(context.getBean("descriptionBean")).isEqualTo("overridden"); + } + + @Test + void createOrReplaceBeanByTypeWithMatchingBean() { + AnnotationConfigApplicationContext context = createContext(ByTypeWithReplaceOrCreateStrategyTestCase.class); + context.registerBean("someBean", String.class, () -> "Original"); + context.refresh(); + + assertThat(context.getBean("someBean")).isEqualTo("overridden"); + } + + @Test + void createOrReplaceBeanByTypeWithoutMatchingDefinitionCreatesBeanDefinition() { + AnnotationConfigApplicationContext context = createContext(ByTypeWithReplaceOrCreateStrategyTestCase.class); + context.refresh(); + + String generatedBeanName = "java.lang.String#0"; + assertThat(context.getBeanDefinitionNames()).contains(generatedBeanName); + assertThat(context.getBean(generatedBeanName)).isEqualTo("overridden"); + } + + @Test + void postProcessorShouldNotTriggerEarlyInitialization() { + AnnotationConfigApplicationContext context = createContext(ByTypeWithReplaceOrCreateStrategyTestCase.class); + + context.register(FactoryBeanRegisteringPostProcessor.class); + context.register(EarlyBeanInitializationDetector.class); + + assertThatNoException().isThrownBy(context::refresh); + } + + @Test + void replaceBeanByNameWithMatchingBeanDefinitionWithExplicitSingletonScope() { + AnnotationConfigApplicationContext context = createContext(ByNameTestCase.class); + RootBeanDefinition definition = new RootBeanDefinition(String.class, () -> "ORIGINAL"); + definition.setScope(BeanDefinition.SCOPE_SINGLETON); + context.registerBeanDefinition("descriptionBean", definition); + + assertThatNoException().isThrownBy(context::refresh); + assertThat(context.isSingleton("descriptionBean")).as("isSingleton").isTrue(); + assertThat(context.getBean("descriptionBean")).isEqualTo("overridden"); + } + + @Test + void replaceBeanByNameWithMatchingBeanDefinitionForClassBasedSingletonFactoryBean() { + String beanName = "descriptionBean"; + AnnotationConfigApplicationContext context = createContext(ByNameTestCase.class); + RootBeanDefinition factoryBeanDefinition = new RootBeanDefinition(SingletonStringFactoryBean.class); + context.registerBeanDefinition(beanName, factoryBeanDefinition); + + assertThatNoException().isThrownBy(context::refresh); + assertThat(context.isSingleton(beanName)).as("isSingleton").isTrue(); + assertThat(context.getBean(beanName)).isEqualTo("overridden"); + } + + @Test // gh-33800 + void replaceBeanByNameWithMatchingBeanDefinitionForClassBasedNonSingletonFactoryBean() { + String beanName = "descriptionBean"; + AnnotationConfigApplicationContext context = createContext(ByNameTestCase.class); + RootBeanDefinition factoryBeanDefinition = new RootBeanDefinition(NonSingletonStringFactoryBean.class); + context.registerBeanDefinition(beanName, factoryBeanDefinition); + + assertThatNoException().isThrownBy(context::refresh); + assertThat(context.isSingleton(beanName)).as("isSingleton").isTrue(); + assertThat(context.getBean(beanName)).isEqualTo("overridden"); + } + + @Test + void replaceBeanByNameWithMatchingBeanDefinitionForInterfaceBasedSingletonFactoryBean() { + String beanName = "messageServiceBean"; + AnnotationConfigApplicationContext context = createContext(MessageServiceTestCase.class); + RootBeanDefinition factoryBeanDefinition = new RootBeanDefinition(SingletonMessageServiceFactoryBean.class); + context.registerBeanDefinition(beanName, factoryBeanDefinition); + + assertThatNoException().isThrownBy(context::refresh); + assertThat(context.isSingleton(beanName)).as("isSingleton").isTrue(); + assertThat(context.getBean(beanName, MessageService.class).getMessage()).isEqualTo("overridden"); + } + + @Test // gh-33800 + void replaceBeanByNameWithMatchingBeanDefinitionForInterfaceBasedNonSingletonFactoryBean() { + String beanName = "messageServiceBean"; + AnnotationConfigApplicationContext context = createContext(MessageServiceTestCase.class); + RootBeanDefinition factoryBeanDefinition = new RootBeanDefinition(NonSingletonMessageServiceFactoryBean.class); + context.registerBeanDefinition(beanName, factoryBeanDefinition); + + assertThatNoException().isThrownBy(context::refresh); + assertThat(context.isSingleton(beanName)).as("isSingleton").isTrue(); + assertThat(context.getBean(beanName, MessageService.class).getMessage()).isEqualTo("overridden"); + } + + @Test + void replaceBeanByNameWithMatchingBeanDefinitionWithPrototypeScopeFails() { + String beanName = "descriptionBean"; + + AnnotationConfigApplicationContext context = createContext(ByNameTestCase.class); + RootBeanDefinition definition = new RootBeanDefinition(String.class, () -> "ORIGINAL"); + definition.setScope(BeanDefinition.SCOPE_PROTOTYPE); + context.registerBeanDefinition(beanName, definition); + + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage("Unable to override bean 'descriptionBean': only singleton beans can be overridden."); + } + + @Test + void replaceBeanByNameWithMatchingBeanDefinitionWithCustomScopeFails() { + String beanName = "descriptionBean"; + String scope = "customScope"; + + AnnotationConfigApplicationContext context = createContext(ByNameTestCase.class); + ConfigurableListableBeanFactory beanFactory = context.getBeanFactory(); + beanFactory.registerScope(scope, new SimpleThreadScope()); + RootBeanDefinition definition = new RootBeanDefinition(String.class, () -> "ORIGINAL"); + definition.setScope(scope); + context.registerBeanDefinition(beanName, definition); + + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage("Unable to override bean 'descriptionBean': only singleton beans can be overridden."); + } + + @Test + void replaceBeanByNameWithMatchingBeanDefinitionForPrototypeScopedFactoryBeanFails() { + String beanName = "messageServiceBean"; + AnnotationConfigApplicationContext context = createContext(MessageServiceTestCase.class); + RootBeanDefinition factoryBeanDefinition = new RootBeanDefinition(SingletonMessageServiceFactoryBean.class); + factoryBeanDefinition.setScope(BeanDefinition.SCOPE_PROTOTYPE); + context.registerBeanDefinition(beanName, factoryBeanDefinition); + + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage("Unable to override bean 'messageServiceBean': only singleton beans can be overridden."); + } + + @Test + void replaceBeanByNameWithMatchingBeanDefinitionRetainsPrimaryAndFallbackFlags() { + AnnotationConfigApplicationContext context = createContext(ByNameTestCase.class); + RootBeanDefinition definition = new RootBeanDefinition(String.class, () -> "ORIGINAL"); + definition.setPrimary(true); + definition.setFallback(true); + context.registerBeanDefinition("descriptionBean", definition); + + assertThatNoException().isThrownBy(context::refresh); + assertThat(context.getBeanDefinition("descriptionBean")) + .matches(BeanDefinition::isPrimary, "isPrimary") + .matches(BeanDefinition::isFallback, "isFallback") + .satisfies(d -> assertThat(d.getScope()).isEqualTo("")) + .matches(BeanDefinition::isSingleton, "isSingleton") + .matches(Predicate.not(BeanDefinition::isPrototype), "!isPrototype"); + } + + @Test + void qualifiedElementIsSetToBeanOverrideFieldForNonexistentBeanDefinition() { + AnnotationConfigApplicationContext context = createContext(TestBeanByNameTestCase.class); + + assertThatNoException().isThrownBy(context::refresh); + assertThat(context.getBeanDefinition("descriptionBean")) + .isInstanceOfSatisfying(RootBeanDefinition.class, this::qualifiedElementIsField); + } + + + private void qualifiedElementIsField(RootBeanDefinition def) { + assertThat(def.getQualifiedElement()).isInstanceOfSatisfying(Field.class, + field -> { + assertThat(field.getDeclaringClass()).isEqualTo(TestBeanByNameTestCase.class); + assertThat(field.getName()).as("annotated field name").isEqualTo("description"); + }); + } + + private static AnnotationConfigApplicationContext createContext(Class testClass) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + Set handlers = new LinkedHashSet<>(BeanOverrideTestUtils.findHandlers(testClass)); + new BeanOverrideContextCustomizer(handlers).customizeContext(context, mock(MergedContextConfiguration.class)); + return context; + } + + + @FunctionalInterface + interface MessageService { + String getMessage(); + } + + static class FactoryBeanPrefixTestCase { + + @DummyBean(beanName = "&messageService") + MessageService messageService; + + } + + static class ByNameTestCase { + + @DummyBean(beanName = "descriptionBean") + private String description; + + } + + static class ByTypeTestCase { + + @DummyBean + private Integer counter; + + } + + static class ByNameWithReplaceOrCreateStrategyTestCase { + + @DummyBean(beanName = "descriptionBean", strategy = BeanOverrideStrategy.REPLACE_OR_CREATE) + private String description; + + } + + static class ByTypeWithReplaceOrCreateStrategyTestCase { + + @DummyBean(strategy = BeanOverrideStrategy.REPLACE_OR_CREATE) + private String description; + + } + + static class ByNameAndByTypeWithReplaceOrCreateStrategyTestCase { + + @DummyBean(beanName = "descriptionBean", strategy = BeanOverrideStrategy.REPLACE_OR_CREATE) + private String description; + + @DummyBean(strategy = BeanOverrideStrategy.REPLACE_OR_CREATE) + private Integer counter; + + } + + static class OverrideBeanProducedByFactoryBeanTestCase { + + @DummyBean(beanName = "beanToBeOverridden") + CharSequence description; + + } + + static class TestBeanByNameTestCase { + + @TestBean(name = "descriptionBean") + String description; + + static String descriptionBean() { + return "overridden"; + } + } + + static class TestBeanByTypeTestCase { + + @TestBean + String description; + + static String description() { + return "overridden"; + } + } + + static class TestFactoryBean implements FactoryBean { + + @Override + public Object getObject() { + return "test"; + } + + @Override + public Class getObjectType() { + return null; + } + + @Override + public boolean isSingleton() { + return true; + } + } + + static class SingletonStringFactoryBean implements FactoryBean { + + @Override + public String getObject() { + return "test"; + } + + @Override + public Class getObjectType() { + return String.class; + } + } + + static class NonSingletonStringFactoryBean extends SingletonStringFactoryBean { + + @Override + public boolean isSingleton() { + return false; + } + } + + static class SingletonMessageServiceFactoryBean implements FactoryBean { + + @Override + public MessageService getObject() { + return () -> "test"; + } + + @Override + public Class getObjectType() { + return MessageService.class; + } + } + + static class NonSingletonMessageServiceFactoryBean extends SingletonMessageServiceFactoryBean { + + @Override + public boolean isSingleton() { + return false; + } + } + + static class MessageServiceTestCase { + + @TestBean(name = "messageServiceBean") + MessageService messageService; + + static MessageService messageService() { + return () -> "overridden"; + } + } + + static class FactoryBeanRegisteringPostProcessor implements BeanFactoryPostProcessor, Ordered { + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) { + RootBeanDefinition beanDefinition = new RootBeanDefinition(TestFactoryBean.class); + ((BeanDefinitionRegistry) beanFactory).registerBeanDefinition("test", beanDefinition); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } + } + + static class EarlyBeanInitializationDetector implements BeanFactoryPostProcessor { + + @Override + @SuppressWarnings("unchecked") + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) { + Map cache = (Map) ReflectionTestUtils.getField(beanFactory, + "factoryBeanInstanceCache"); + Assert.isTrue(cache.isEmpty(), "Early initialization of factory bean triggered."); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizerFactoryTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizerFactoryTests.java new file mode 100644 index 000000000000..2ed2498993e2 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizerFactoryTests.java @@ -0,0 +1,131 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.util.Collections; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import org.springframework.lang.Nullable; +import org.springframework.test.context.bean.override.DummyBean.DummyBeanOverrideProcessor.DummyBeanOverrideHandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link BeanOverrideContextCustomizerFactory}. + * + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + */ +class BeanOverrideContextCustomizerFactoryTests { + + private final BeanOverrideContextCustomizerFactory factory = new BeanOverrideContextCustomizerFactory(); + + @Test + void createContextCustomizerWhenTestHasNoBeanOverride() { + assertThat(createContextCustomizer(String.class)).isNull(); + } + + @Test + void createContextCustomizerWhenTestHasSingleBeanOverride() { + BeanOverrideContextCustomizer customizer = createContextCustomizer(Test1.class); + assertThat(customizer).isNotNull(); + assertThat(customizer.getBeanOverrideHandlers()).singleElement().satisfies(dummyHandler(null, String.class)); + } + + @Test + void createContextCustomizerWhenNestedTestHasSingleBeanOverrideInParent() { + BeanOverrideContextCustomizer customizer = createContextCustomizer(Test2.Orange.class); + assertThat(customizer).isNotNull(); + assertThat(customizer.getBeanOverrideHandlers()).singleElement().satisfies(dummyHandler(null, String.class)); + } + + @Test + void createContextCustomizerWhenNestedTestHasBeanOverrideAsWellAsTheParent() { + BeanOverrideContextCustomizer customizer = createContextCustomizer(Test2.Green.class); + assertThat(customizer).isNotNull(); + assertThat(customizer.getBeanOverrideHandlers()) + .anySatisfy(dummyHandler(null, String.class)) + .anySatisfy(dummyHandler("counterBean", Integer.class)) + .hasSize(2); + } + + @Test // gh-34054 + void failsWithDuplicateBeanOverrides() { + Class testClass = DuplicateOverridesTestCase.class; + assertThatIllegalStateException() + .isThrownBy(() -> createContextCustomizer(testClass)) + .withMessageStartingWith("Duplicate BeanOverrideHandler discovered in test class " + testClass.getName()) + .withMessageContaining("DummyBeanOverrideHandler"); + } + + + private Consumer dummyHandler(@Nullable String beanName, Class beanType) { + return dummyHandler(beanName, beanType, BeanOverrideStrategy.REPLACE); + } + + private Consumer dummyHandler(@Nullable String beanName, Class beanType, BeanOverrideStrategy strategy) { + return handler -> { + assertThat(handler).isExactlyInstanceOf(DummyBeanOverrideHandler.class); + assertThat(handler.getBeanName()).isEqualTo(beanName); + assertThat(handler.getBeanType().toClass()).isEqualTo(beanType); + assertThat(handler.getStrategy()).isEqualTo(strategy); + }; + } + + @Nullable + private BeanOverrideContextCustomizer createContextCustomizer(Class testClass) { + return this.factory.createContextCustomizer(testClass, Collections.emptyList()); + } + + + static class Test1 { + + @DummyBean + private String descriptor; + } + + static class Test2 { + + @DummyBean + private String name; + + // @Nested + class Orange { + } + + // @Nested + class Green { + + @DummyBean(beanName = "counterBean") + private Integer counter; + } + } + + static class DuplicateOverridesTestCase { + + @DummyBean(beanName = "text") + String text1; + + @DummyBean(beanName = "text") + String text2; + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizerTestUtils.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizerTestUtils.java new file mode 100644 index 000000000000..9e01f72ca87e --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizerTestUtils.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.util.Collections; + +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.lang.Nullable; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.MergedContextConfiguration; + +import static org.mockito.Mockito.mock; + +/** + * Test utilities for {@link BeanOverrideContextCustomizer} that are public so + * that specific bean override implementations can use them. + * + * @author Stephane Nicoll + */ +public abstract class BeanOverrideContextCustomizerTestUtils { + + private static final BeanOverrideContextCustomizerFactory factory = new BeanOverrideContextCustomizerFactory(); + + /** + * Create a {@link ContextCustomizer} for the given {@code testClass}. Return + * a customizer to handle any use of {@link BeanOverride} or {@code null} if + * the test class does not use them. + * @param testClass a test class to introspect + * @return a context customizer for bean override support, or null + */ + @Nullable + public static ContextCustomizer createContextCustomizer(Class testClass) { + return factory.createContextCustomizer(testClass, Collections.emptyList()); + } + + /** + * Customize the given {@linkplain ConfigurableApplicationContext application + * context} for the given {@code testClass}. + * @param testClass the test to process + * @param context the context to customize + */ + public static void customizeApplicationContext(Class testClass, ConfigurableApplicationContext context) { + ContextCustomizer contextCustomizer = createContextCustomizer(testClass); + if (contextCustomizer != null) { + contextCustomizer.customizeContext(context, mock(MergedContextConfiguration.class)); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizerTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizerTests.java new file mode 100644 index 000000000000..8944aeb2be3f --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizerTests.java @@ -0,0 +1,103 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Objects; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.core.ResolvableType; +import org.springframework.lang.Nullable; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link BeanOverrideContextCustomizer}. + * + * @author Stephane Nicoll + */ +class BeanOverrideContextCustomizerTests { + + @Test + void customizerIsEqualWithIdenticalMetadata() { + BeanOverrideContextCustomizer customizer = createCustomizer(new DummyBeanOverrideHandler("key")); + BeanOverrideContextCustomizer customizer2 = createCustomizer(new DummyBeanOverrideHandler("key")); + assertThat(customizer).isEqualTo(customizer2); + assertThat(customizer).hasSameHashCodeAs(customizer2); + } + + @Test + void customizerIsEqualWithIdenticalMetadataInDifferentOrder() { + BeanOverrideContextCustomizer customizer = createCustomizer( + new DummyBeanOverrideHandler("key1"), new DummyBeanOverrideHandler("key2")); + BeanOverrideContextCustomizer customizer2 = createCustomizer( + new DummyBeanOverrideHandler("key2"), new DummyBeanOverrideHandler("key1")); + assertThat(customizer).isEqualTo(customizer2); + assertThat(customizer).hasSameHashCodeAs(customizer2); + } + + @Test + void customizerIsNotEqualWithDifferentMetadata() { + BeanOverrideContextCustomizer customizer = createCustomizer(new DummyBeanOverrideHandler("key")); + BeanOverrideContextCustomizer customizer2 = createCustomizer( + new DummyBeanOverrideHandler("key"), new DummyBeanOverrideHandler("another")); + assertThat(customizer).isNotEqualTo(customizer2); + } + + private BeanOverrideContextCustomizer createCustomizer(BeanOverrideHandler... handlers) { + return new BeanOverrideContextCustomizer(new LinkedHashSet<>(Arrays.asList(handlers))); + } + + private static class DummyBeanOverrideHandler extends BeanOverrideHandler { + + private final String key; + + public DummyBeanOverrideHandler(String key) { + super(ReflectionUtils.findField(DummyBeanOverrideHandler.class, "key"), + ResolvableType.forClass(Object.class), null, BeanOverrideStrategy.REPLACE); + this.key = key; + } + + @Override + protected Object createOverrideInstance(String beanName, BeanDefinition existingBeanDefinition, + Object existingBeanInstance) { + return existingBeanInstance; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DummyBeanOverrideHandler that = (DummyBeanOverrideHandler) o; + return Objects.equals(this.key, that.key); + } + + @Override + public int hashCode() { + return this.key.hashCode(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideHandlerTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideHandlerTests.java new file mode 100644 index 000000000000..5229cf5b44dd --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideHandlerTests.java @@ -0,0 +1,300 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.lang.Nullable; +import org.springframework.test.context.bean.override.DummyBean.DummyBeanOverrideProcessor.DummyBeanOverrideHandler; +import org.springframework.test.context.bean.override.example.CustomQualifier; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +/** + * Tests for {@link BeanOverrideHandler}. + * + * @author Simon Baslé + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + */ +class BeanOverrideHandlerTests { + + @Test + void forTestClassWithSingleField() { + List handlers = BeanOverrideTestUtils.findHandlers(SingleAnnotation.class); + assertThat(handlers).singleElement().satisfies(hasBeanOverrideHandler( + field(SingleAnnotation.class, "message"), String.class, null)); + } + + @Test + void forTestClassWithMultipleFields() { + List handlers = BeanOverrideTestUtils.findHandlers(MultipleAnnotations.class); + assertThat(handlers).hasSize(2) + .anySatisfy(hasBeanOverrideHandler( + field(MultipleAnnotations.class, "message"), String.class, null)) + .anySatisfy(hasBeanOverrideHandler( + field(MultipleAnnotations.class, "counter"), Integer.class, null)); + } + + @Test + void forTestClassWithMultipleFieldsWithIdenticalMetadata() { + List handlers = BeanOverrideTestUtils.findHandlers(MultipleAnnotationsDuplicate.class); + assertThat(handlers).hasSize(2) + .anySatisfy(hasBeanOverrideHandler( + field(MultipleAnnotationsDuplicate.class, "message1"), String.class, "messageBean")) + .anySatisfy(hasBeanOverrideHandler( + field(MultipleAnnotationsDuplicate.class, "message2"), String.class, "messageBean")); + assertThat(new HashSet<>(handlers)).hasSize(1); + } + + @Test + void forTestClassWithCompetingBeanOverrideAnnotationsOnSameField() { + Field faultyField = field(MultipleAnnotationsOnSameField.class, "message"); + assertThatIllegalStateException() + .isThrownBy(() -> BeanOverrideTestUtils.findHandlers(MultipleAnnotationsOnSameField.class)) + .withMessageStartingWith("Multiple @BeanOverride annotations found") + .withMessageContaining(faultyField.toString()); + } + + @Test // gh-33922 + void forTestClassWithStaticBeanOverrideField() { + Field staticField = field(StaticBeanOverrideField.class, "message"); + assertThatIllegalStateException() + .isThrownBy(() -> BeanOverrideTestUtils.findHandlers(StaticBeanOverrideField.class)) + .withMessage("@BeanOverride field must not be static: " + staticField); + } + + @Test + void getBeanNameIsNullByDefault() { + BeanOverrideHandler handler = createBeanOverrideHandler(field(ConfigA.class, "noQualifier")); + assertThat(handler.getBeanName()).isNull(); + } + + @Test + void isEqualToWithSameInstance() { + BeanOverrideHandler handler = createBeanOverrideHandler(field(ConfigA.class, "noQualifier")); + assertThat(handler).isEqualTo(handler); + assertThat(handler).hasSameHashCodeAs(handler); + } + + @Test + void isEqualToWithSameMetadata() { + BeanOverrideHandler handler1 = createBeanOverrideHandler(field(ConfigA.class, "noQualifier")); + BeanOverrideHandler handler2 = createBeanOverrideHandler(field(ConfigA.class, "noQualifier")); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test + void isEqualToWithSameMetadataAndBeanNames() { + BeanOverrideHandler handler1 = createBeanOverrideHandler(field(ConfigA.class, "noQualifier"), "testBean"); + BeanOverrideHandler handler2 = createBeanOverrideHandler(field(ConfigA.class, "noQualifier"), "testBean"); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test + void isNotEqualToWithSameMetadataAndDifferentBeaName() { + BeanOverrideHandler handler1 = createBeanOverrideHandler(field(ConfigA.class, "noQualifier"), "testBean"); + BeanOverrideHandler handler2 = createBeanOverrideHandler(field(ConfigA.class, "noQualifier"), "testBean2"); + assertThat(handler1).isNotEqualTo(handler2); + } + + @Test + void isEqualToWithSameMetadataButDifferentFields() { + BeanOverrideHandler handler1 = createBeanOverrideHandler(field(ConfigA.class, "noQualifier")); + BeanOverrideHandler handler2 = createBeanOverrideHandler(field(ConfigB.class, "noQualifier")); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test + void isEqualToWithByNameLookupAndDifferentFieldNames() { + BeanOverrideHandler handler1 = createBeanOverrideHandler(field(ConfigA.class, "noQualifier"), "beanToOverride"); + BeanOverrideHandler handler2 = createBeanOverrideHandler(field(ConfigB.class, "example"), "beanToOverride"); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test + void isEqualToWithSameMetadataAndSameQualifierValues() { + BeanOverrideHandler handler1 = createBeanOverrideHandler(field(ConfigA.class, "directQualifier")); + BeanOverrideHandler handler2 = createBeanOverrideHandler(field(ConfigB.class, "directQualifier")); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test + void isEqualToWithSameMetadataAndSameQualifierValuesButWithAnnotationsDeclaredInDifferentOrder() { + Field field1 = field(ConfigA.class, "qualifiedDummyBean"); + Field field2 = field(ConfigB.class, "qualifiedDummyBean"); + + // Prerequisite + assertThat(Arrays.equals(field1.getAnnotations(), field2.getAnnotations())).isFalse(); + + BeanOverrideHandler handler1 = createBeanOverrideHandler(field1); + BeanOverrideHandler handler2 = createBeanOverrideHandler(field2); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test + void isNotEqualToWithSameMetadataAndDifferentQualifierValues() { + BeanOverrideHandler handler1 = createBeanOverrideHandler(field(ConfigA.class, "directQualifier")); + BeanOverrideHandler handler2 = createBeanOverrideHandler(field(ConfigA.class, "differentDirectQualifier")); + assertThat(handler1).isNotEqualTo(handler2); + } + + @Test + void isNotEqualToWithSameMetadataAndDifferentQualifiers() { + BeanOverrideHandler handler1 = createBeanOverrideHandler(field(ConfigA.class, "directQualifier")); + BeanOverrideHandler handler2 = createBeanOverrideHandler(field(ConfigA.class, "customQualifier")); + assertThat(handler1).isNotEqualTo(handler2); + } + + @Test + void isNotEqualToWithByTypeLookupAndDifferentFieldNames() { + BeanOverrideHandler handler1 = createBeanOverrideHandler(field(ConfigA.class, "noQualifier")); + BeanOverrideHandler handler2 = createBeanOverrideHandler(field(ConfigB.class, "example")); + assertThat(handler1).isNotEqualTo(handler2); + } + + private static BeanOverrideHandler createBeanOverrideHandler(Field field) { + return createBeanOverrideHandler(field, null); + } + + private static BeanOverrideHandler createBeanOverrideHandler(Field field, @Nullable String name) { + return new DummyBeanOverrideHandler(field, field.getType(), name, BeanOverrideStrategy.REPLACE); + } + + private static Field field(Class target, String fieldName) { + Field field = ReflectionUtils.findField(target, fieldName); + assertThat(field).isNotNull(); + return field; + } + + private static Consumer hasBeanOverrideHandler(Field field, Class beanType, @Nullable String beanName) { + return hasBeanOverrideHandler(field, beanType, BeanOverrideStrategy.REPLACE, beanName); + } + + private static Consumer hasBeanOverrideHandler(Field field, Class beanType, BeanOverrideStrategy strategy, + @Nullable String beanName) { + + return handler -> assertSoftly(softly -> { + softly.assertThat(handler.getField()).as("field").isEqualTo(field); + softly.assertThat(handler.getBeanType().toClass()).as("type").isEqualTo(beanType); + softly.assertThat(handler.getBeanName()).as("name").isEqualTo(beanName); + softly.assertThat(handler.getStrategy()).as("strategy").isEqualTo(strategy); + }); + } + + + static class SingleAnnotation { + + @DummyBean + String message; + } + + static class MultipleAnnotations { + + @DummyBean + String message; + + @DummyBean + Integer counter; + } + + static class MultipleAnnotationsDuplicate { + + @DummyBean(beanName = "messageBean") + String message1; + + @DummyBean(beanName = "messageBean") + String message2; + } + + static class MultipleAnnotationsOnSameField { + + @MetaDummyBean() + @DummyBean + String message; + + static String foo() { + return "foo"; + } + } + + static class StaticBeanOverrideField { + + @DummyBean + static String message; + } + + static class ConfigA { + + ExampleService noQualifier; + + @Qualifier("test") + ExampleService directQualifier; + + @Qualifier("different") + ExampleService differentDirectQualifier; + + @CustomQualifier + ExampleService customQualifier; + + @DummyBean + @Qualifier("test") + ExampleService qualifiedDummyBean; + } + + static class ConfigB { + + ExampleService noQualifier; + + ExampleService example; + + @Qualifier("test") + ExampleService directQualifier; + + @Qualifier("test") + @DummyBean + ExampleService qualifiedDummyBean; + } + + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @DummyBean + @interface MetaDummyBean {} + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideTestSuite.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideTestSuite.java new file mode 100644 index 000000000000..4d6049f6f9bd --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideTestSuite.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import org.junit.jupiter.api.ClassOrderer; +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.ExcludeTags; +import org.junit.platform.suite.api.IncludeClassNamePatterns; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.SelectPackages; +import org.junit.platform.suite.api.Suite; + +/** + * JUnit Platform based test suite for tests that involve bean override support + * in the Spring TestContext Framework. + * + *

    This suite is only intended to be used manually within an IDE. + * + *

    Logging Configuration

    + * + *

    In order for our log4j2 configuration to be used in an IDE, you must + * set the following system property before running any tests — for + * example, in Run Configurations in Eclipse. + * + *

    + * -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager
    + * 
    + * + * @author Sam Brannen + * @since 6.2 + */ +@Suite +@IncludeEngines("junit-jupiter") +@SelectPackages("org.springframework.test.context.bean.override") +@IncludeClassNamePatterns(".*Tests$") +@ExcludeTags("failing-test-case") +@ConfigurationParameter( + key = ClassOrderer.DEFAULT_ORDER_PROPERTY_NAME, + value = "org.junit.jupiter.api.ClassOrderer$ClassName" +) +public class BeanOverrideTestSuite { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideTestUtils.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideTestUtils.java new file mode 100644 index 000000000000..994ffffa35d1 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideTestUtils.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.util.List; + +/** + * Test utilities for Bean Overrides. + * + * @author Sam Brannen + * @since 6.2.2 + */ +public abstract class BeanOverrideTestUtils { + + public static List findHandlers(Class testClass) { + return BeanOverrideHandler.forTestClass(testClass); + } + + public static List findAllHandlers(Class testClass) { + return BeanOverrideHandler.findAllHandlers(testClass); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/DummyBean.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/DummyBean.java new file mode 100644 index 000000000000..d6beaf4ba306 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/DummyBean.java @@ -0,0 +1,86 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override; + +import java.lang.annotation.Annotation; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Field; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.core.ResolvableType; +import org.springframework.lang.Nullable; +import org.springframework.test.context.bean.override.DummyBean.DummyBeanOverrideProcessor; +import org.springframework.util.StringUtils; + +/** + * A dummy {@link BeanOverride} implementation that only handles {@link CharSequence} + * and {@link Integer} and replace them with {@code "overridden"} and {@code 42}, + * respectively. + * + * @author Stephane Nicoll + */ +@Target({ ElementType.FIELD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@BeanOverride(DummyBeanOverrideProcessor.class) +@interface DummyBean { + + String beanName() default ""; + + BeanOverrideStrategy strategy() default BeanOverrideStrategy.REPLACE; + + class DummyBeanOverrideProcessor implements BeanOverrideProcessor { + + @Override + public BeanOverrideHandler createHandler(Annotation annotation, Class testClass, Field field) { + DummyBean dummyBean = (DummyBean) annotation; + String beanName = (StringUtils.hasText(dummyBean.beanName()) ? dummyBean.beanName() : null); + return new DummyBeanOverrideProcessor.DummyBeanOverrideHandler(field, field.getType(), beanName, + dummyBean.strategy()); + } + + // Bare bone, "dummy", implementation that should not override anything + // other than createOverrideInstance(). + static class DummyBeanOverrideHandler extends BeanOverrideHandler { + + DummyBeanOverrideHandler(Field field, Class typeToOverride, @Nullable String beanName, + BeanOverrideStrategy strategy) { + + super(field, ResolvableType.forClass(typeToOverride), beanName, strategy); + } + + @Override + protected Object createOverrideInstance(String beanName, @Nullable BeanDefinition existingBeanDefinition, + @Nullable Object existingBeanInstance) { + + Class beanType = getField().getType(); + if (CharSequence.class.isAssignableFrom(beanType)) { + return "overridden"; + } + else if (Integer.class.isAssignableFrom(beanType)) { + return 42; + } + throw new IllegalStateException("Could not handle bean type " + beanType); + } + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanContextCustomizerEqualityTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanContextCustomizerEqualityTests.java new file mode 100644 index 000000000000..76d4a0ad72cb --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanContextCustomizerEqualityTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import org.junit.jupiter.api.Test; + +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.bean.override.BeanOverrideContextCustomizerTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests that validate the behavior of {@link TestBean} with the TCF context cache. + * + * @author Stephane Nicoll + */ +class TestBeanContextCustomizerEqualityTests { + + @Test + void contextCustomizerWithSameOverrideInDifferentTestClassesIsEqual() { + assertThat(createContextCustomizer(Case1.class)).isEqualTo(createContextCustomizer(Case2.class)); + } + + @Test + void contextCustomizerWithDifferentMethodsIsNotEqual() { + assertThat(createContextCustomizer(Case1.class)).isNotEqualTo(createContextCustomizer(Case3.class)); + } + + @Test + void contextCustomizerWithByNameVsByTypeLookupIsNotEqual() { + assertThat(createContextCustomizer(Case4.class)).isNotEqualTo(createContextCustomizer(Case5.class)); + } + + + private static ContextCustomizer createContextCustomizer(Class testClass) { + ContextCustomizer customizer = BeanOverrideContextCustomizerTestUtils.createContextCustomizer(testClass); + assertThat(customizer).isNotNull(); + return customizer; + } + + interface DescriptionProvider { + + static String createDescription() { + return "override"; + } + + } + + static class Case1 implements DescriptionProvider { + + @TestBean(methodName = "createDescription") + private String description; + + } + + static class Case2 implements DescriptionProvider { + + @TestBean(methodName = "createDescription") + private String description; + + } + + static class Case3 implements DescriptionProvider { + + @TestBean(methodName = "createDescription") + private String description; + + static String createDescription() { + return "another value"; + } + } + + static class Case4 { + + @TestBean + private String description; + + static String description() { + return "overridden"; + } + } + + static class Case5 { + + @TestBean(name = "descriptionBean") + private String description; + + static String description() { + return "overridden"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForByNameLookupIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForByNameLookupIntegrationTests.java new file mode 100644 index 000000000000..63dfd4e96043 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForByNameLookupIntegrationTests.java @@ -0,0 +1,185 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link TestBean} that use by-name lookup. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +public class TestBeanForByNameLookupIntegrationTests { + + @TestBean(name = "field") + String field; + + @TestBean(name = "methodRenamed1", methodName = "field") + String methodRenamed1; + + static String field() { + return "fieldOverride"; + } + + static String nestedField() { + return "nestedFieldOverride"; + } + + @Test + void fieldHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("field")).as("applicationContext").isEqualTo("fieldOverride"); + assertThat(field).as("injection point").isEqualTo("fieldOverride"); + } + + @Test + void fieldWithMethodNameHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("methodRenamed1")).as("applicationContext").isEqualTo("fieldOverride"); + assertThat(methodRenamed1).as("injection point").isEqualTo("fieldOverride"); + } + + + @Nested + @DisplayName("With @TestBean in enclosing class and in @Nested class") + public class TestBeanFieldInEnclosingClassTests { + + @TestBean(name = "nestedField") + String nestedField; + + @TestBean(name = "methodRenamed2", methodName = "nestedField") + String methodRenamed2; + + + @Test + void fieldHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("field")).as("applicationContext").isEqualTo("fieldOverride"); + assertThat(field).as("injection point").isEqualTo("fieldOverride"); + } + + @Test + void fieldWithMethodNameHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("methodRenamed1")).as("applicationContext").isEqualTo("fieldOverride"); + assertThat(methodRenamed1).as("injection point").isEqualTo("fieldOverride"); + } + + @Test + void nestedFieldHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("nestedField")).as("applicationContext").isEqualTo("nestedFieldOverride"); + assertThat(nestedField).isEqualTo("nestedFieldOverride"); + } + + @Test + void nestedFieldWithMethodNameHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("methodRenamed2")).as("applicationContext").isEqualTo("nestedFieldOverride"); + assertThat(methodRenamed2).isEqualTo("nestedFieldOverride"); + } + + @Nested + @DisplayName("With @TestBean in the enclosing classes") + public class TestBeanFieldInEnclosingClassLevel2Tests { + + @Test + void fieldHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("field")).as("applicationContext").isEqualTo("fieldOverride"); + assertThat(field).as("injection point").isEqualTo("fieldOverride"); + } + + @Test + void fieldWithMethodNameHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("methodRenamed1")).as("applicationContext").isEqualTo("fieldOverride"); + assertThat(methodRenamed1).as("injection point").isEqualTo("fieldOverride"); + } + + @Test + void nestedFieldHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("nestedField")).as("applicationContext").isEqualTo("nestedFieldOverride"); + assertThat(nestedField).isEqualTo("nestedFieldOverride"); + } + + @Test + void nestedFieldWithMethodNameHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("methodRenamed2")).as("applicationContext").isEqualTo("nestedFieldOverride"); + assertThat(methodRenamed2).isEqualTo("nestedFieldOverride"); + } + } + } + + @Nested + @DisplayName("With factory method in enclosing class") + public class TestBeanFactoryMethodInEnclosingClassTests { + + @TestBean(methodName = "nestedField", name = "nestedField") + String nestedField; + + @Test + void nestedFieldHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("nestedField")).as("applicationContext").isEqualTo("nestedFieldOverride"); + assertThat(nestedField).isEqualTo("nestedFieldOverride"); + } + + @Nested + @DisplayName("With factory method in the enclosing class of the enclosing class") + public class TestBeanFactoryMethodInEnclosingClassLevel2Tests { + + @TestBean(methodName = "nestedField", name = "nestedNestedField") + String nestedNestedField; + + @Test + void nestedFieldHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("nestedNestedField")).as("applicationContext").isEqualTo("nestedFieldOverride"); + assertThat(nestedNestedField).isEqualTo("nestedFieldOverride"); + } + } + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean("field") + String bean1() { + return "prod"; + } + + @Bean("nestedField") + String bean2() { + return "nestedProd"; + } + + @Bean("methodRenamed1") + String bean3() { + return "Prod"; + } + + @Bean("methodRenamed2") + String bean4() { + return "NestedProd"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForByTypeLookupIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForByTypeLookupIntegrationTests.java new file mode 100644 index 000000000000..d410c8a280ba --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForByTypeLookupIntegrationTests.java @@ -0,0 +1,137 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.example.CustomQualifier; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.RealExampleService; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link TestBean} that use by-type lookup. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +public class TestBeanForByTypeLookupIntegrationTests { + + @TestBean + MessageService messageService; + + @TestBean + ExampleService anyNameForService; + + @TestBean(methodName = "someString") + @Qualifier("prefer") + StringBuilder anyNameForStringBuilder; + + @TestBean(methodName = "someString2") + @CustomQualifier + StringBuilder anyNameForStringBuilder2; + + + static MessageService messageService() { + return () -> "mocked nonexistent bean definition"; + } + + static ExampleService anyNameForService() { + return new RealExampleService("Mocked greeting"); + } + + static StringBuilder someString() { + return new StringBuilder("Prefer TestBean String"); + } + + static StringBuilder someString2() { + return new StringBuilder("CustomQualifier TestBean String"); + } + + + @Test + void overrideIsFoundByTypeForNonexistentBeanDefinition(ApplicationContext ctx) { + assertThat(this.messageService).isSameAs(ctx.getBean(MessageService.class)); + assertThat(this.messageService.getMessage()).isEqualTo("mocked nonexistent bean definition"); + } + + @Test + void overrideIsFoundByType(ApplicationContext ctx) { + assertThat(this.anyNameForService) + .isSameAs(ctx.getBean("example")) + .isSameAs(ctx.getBean(ExampleService.class)); + + assertThat(this.anyNameForService.greeting()).isEqualTo("Mocked greeting"); + } + + @Test + void overrideIsFoundByTypeWithQualifierDisambiguation(ApplicationContext ctx) { + assertThat(this.anyNameForStringBuilder) + .as("direct qualifier") + .isSameAs(ctx.getBean("two")) + .hasToString("Prefer TestBean String"); + + assertThat(this.anyNameForStringBuilder2) + .as("meta qualifier") + .isSameAs(ctx.getBean("three")) + .hasToString("CustomQualifier TestBean String"); + + assertThat(ctx.getBean("one")).as("no qualifier needed").hasToString("Prod One"); + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean("example") + ExampleService bean1() { + return new RealExampleService("Production hello"); + } + + @Bean("one") + StringBuilder beanString1() { + return new StringBuilder("Prod One"); + } + + @Bean("two") + @Qualifier("prefer") + StringBuilder beanString2() { + return new StringBuilder("Prod Two"); + } + + @Bean("three") + @CustomQualifier + StringBuilder beanString3() { + return new StringBuilder("Prod Three"); + } + } + + @FunctionalInterface + public interface MessageService { + + String getMessage(); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForExternalFactoryMethodIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForExternalFactoryMethodIntegrationTests.java new file mode 100644 index 000000000000..f9504ae4542d --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForExternalFactoryMethodIntegrationTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link TestBean} that use bean factory methods defined + * in external classes. + * + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +class TestBeanForExternalFactoryMethodIntegrationTests { + + @TestBean(methodName = "org.springframework.test.context.bean.override.example.TestBeanFactory#createTestMessage") + String message; + + + @Test + void test() { + assertThat(message).isEqualTo("test"); + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + String message() { + return "prod"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForInterfaceIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForInterfaceIntegrationTests.java new file mode 100644 index 000000000000..8aa290c3ceac --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForInterfaceIntegrationTests.java @@ -0,0 +1,57 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.example.TestBeanFactory; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link TestBean} that use bean factory methods defined + * in implemented interfaces. + * + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +class TestBeanForInterfaceIntegrationTests implements TestBeanFactory { + + @TestBean(methodName = "createTestMessage") + String message; + + + @Test + void test() { + assertThat(message).isEqualTo("test"); + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + String message() { + return "prod"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForMultipleNestingLevelsIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForMultipleNestingLevelsIntegrationTests.java new file mode 100644 index 000000000000..0107b06191f4 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanForMultipleNestingLevelsIntegrationTests.java @@ -0,0 +1,131 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link TestBean} that use multiple levels of + * {@link Nested @Nested} test classes. + * + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +class TestBeanForMultipleNestingLevelsIntegrationTests { + + @TestBean(name = "field0", methodName = "testField0") + String field0; + + static String testField0() { + return "zero"; + } + + static String testField1() { + return "one"; + } + + static String testField2() { + return "two"; + } + + @Test + void test() { + assertThat(field0).isEqualTo("zero"); + } + + + @Nested + class NestedLevel1Tests { + + @TestBean(name = "field1", methodName = "testField1") + String field1; + + @Test + void test() { + assertThat(field0).isEqualTo("zero"); + assertThat(field1).isEqualTo("one"); + } + + @Nested + class NestedLevel2Tests { + + @TestBean(name = "field2", methodName = "testField2") + String field2; + + @Test + void test() { + assertThat(field0).isEqualTo("zero"); + assertThat(field1).isEqualTo("one"); + assertThat(field2).isEqualTo("two"); + } + + @Nested + class NestedLevel3Tests { + + @TestBean(name = "field3", methodName = "testField2") + String localField2; + + // Local testField2() "hides" the method in the top-level enclosing class. + static String testField2() { + return "Local Two"; + } + + @Test + void test() { + assertThat(field0).isEqualTo("zero"); + assertThat(field1).isEqualTo("one"); + assertThat(field2).isEqualTo("two"); + assertThat(localField2).isEqualTo("Local Two"); + } + } + } + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + String field0() { + return "replace me 0"; + } + + @Bean + String field1() { + return "replace me 1"; + } + + @Bean + String field2() { + return "replace me 2"; + } + + @Bean + String field3() { + return "replace me 3"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanInheritanceIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanInheritanceIntegrationTests.java new file mode 100644 index 000000000000..ade77e9dc837 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanInheritanceIntegrationTests.java @@ -0,0 +1,213 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link TestBean} that use inheritance. + * + *

    Tests inheritance within a class hierarchy as well as "inheritance" within + * an enclosing class hierarchy. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +public class TestBeanInheritanceIntegrationTests { + + @TestBean + Pojo puzzleBean; + + static Pojo puzzleBean() { + return new FakePojo("puzzle in enclosing class"); + } + + static Pojo enclosingClassFactoryMethod() { + return new FakePojo("in enclosing test class"); + } + + abstract static class AbstractTestCase { + + @TestBean("otherBean") + Pojo otherBean; + + @TestBean + Pojo anotherBean; + + @TestBean + Pojo enigmaBean; + + static Pojo otherBean() { + return new FakePojo("other in superclass"); + } + + static Pojo anotherBean() { + return new FakePojo("another in superclass"); + } + + static Pojo enigmaBean() { + return new FakePojo("enigma in superclass"); + } + + static Pojo commonBean() { + return new FakePojo("common in superclass"); + } + } + + @Nested + @DisplayName("Nested, concrete inherited tests with correct @TestBean setup") + class NestedTests extends AbstractTestCase { + + @Autowired + ApplicationContext ctx; + + @TestBean(methodName = "commonBean") + Pojo pojo; + + @TestBean(name = "pojo2", methodName = "enclosingClassFactoryMethod") + Pojo pojo2; + + @TestBean + Pojo enigmaBean; + + @TestBean + Pojo puzzleBean; + + + // "Overrides" puzzleBean() defined in TestBeanInheritanceIntegrationTests. + static Pojo puzzleBean() { + return new FakePojo("puzzle in nested class"); + } + + // "Overrides" enigmaBean() defined in AbstractTestCase. + static Pojo enigmaBean() { + return new FakePojo("enigma in subclass"); + } + + static Pojo otherBean() { + return new FakePojo("other in subclass"); + } + + @Test + void fieldInSuperclassWithFactoryMethodInSuperclass() { + assertThat(ctx.getBean("anotherBean")).as("applicationContext").hasToString("another in superclass"); + assertThat(super.anotherBean.value()).as("injection point").isEqualTo("another in superclass"); + } + + @Test // gh-34204 + void fieldInSuperclassWithFactoryMethodInSuperclassAndInSubclass() { + // We do not expect "other in subclass", because the @TestBean declaration in + // AbstractTestCase cannot "see" the otherBean() factory method in the subclass. + assertThat(ctx.getBean("otherBean")).as("applicationContext").hasToString("other in superclass"); + assertThat(super.otherBean.value()).as("injection point").isEqualTo("other in superclass"); + } + + @Test + void fieldInSubclassWithFactoryMethodInSuperclass() { + assertThat(ctx.getBean("pojo")).as("applicationContext").hasToString("common in superclass"); + assertThat(this.pojo.value()).as("injection point").isEqualTo("common in superclass"); + } + + @Test + void fieldInNestedClassWithFactoryMethodInEnclosingClass() { + assertThat(ctx.getBean("pojo2")).as("applicationContext").hasToString("in enclosing test class"); + assertThat(this.pojo2.value()).as("injection point").isEqualTo("in enclosing test class"); + } + + @Test // gh-34194, gh-34204 + void testBeanInSubclassOverridesTestBeanInSuperclass() { + assertThat(ctx.getBean("enigmaBean")).as("applicationContext").hasToString("enigma in subclass"); + assertThat(this.enigmaBean.value()).as("injection point").isEqualTo("enigma in subclass"); + } + + @Test // gh-34194, gh-34204 + void testBeanInNestedClassOverridesTestBeanInEnclosingClass() { + assertThat(ctx.getBean("puzzleBean")).as("applicationContext").hasToString("puzzle in nested class"); + assertThat(this.puzzleBean.value()).as("injection point").isEqualTo("puzzle in nested class"); + } + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + Pojo otherBean() { + return new ProdPojo(); + } + + @Bean + Pojo anotherBean() { + return new ProdPojo(); + } + + @Bean + Pojo enigmaBean() { + return new ProdPojo(); + } + + @Bean + Pojo puzzleBean() { + return new ProdPojo(); + } + + @Bean + Pojo pojo() { + return new ProdPojo(); + } + + @Bean + Pojo pojo2() { + return new ProdPojo(); + } + } + + interface Pojo { + + default String value() { + return "prod"; + } + } + + static class ProdPojo implements Pojo { + + @Override + public String toString() { + return value(); + } + } + + record FakePojo(String value) implements Pojo { + + @Override + public String toString() { + return value(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideHandlerTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideHandlerTests.java new file mode 100644 index 000000000000..f95fe62912a7 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideHandlerTests.java @@ -0,0 +1,191 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.ResolvableType; +import org.springframework.test.context.bean.override.BeanOverrideHandler; +import org.springframework.test.context.bean.override.BeanOverrideStrategy; +import org.springframework.test.context.bean.override.BeanOverrideTestUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link TestBeanOverrideHandler}. + * + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + */ +class TestBeanOverrideHandlerTests { + + @Test + void beanNameIsSetToNullIfAnnotationNameIsEmpty() { + List handlers = BeanOverrideTestUtils.findHandlers(SampleOneOverride.class); + assertThat(handlers).singleElement().extracting(BeanOverrideHandler::getBeanName).isNull(); + } + + @Test + void beanNameIsSetToAnnotationName() { + List handlers = BeanOverrideTestUtils.findHandlers(SampleOneOverrideWithName.class); + assertThat(handlers).singleElement().extracting(BeanOverrideHandler::getBeanName).isEqualTo("anotherBean"); + } + + @Test + void failsWithMissingMethod() { + assertThatIllegalStateException() + .isThrownBy(() -> BeanOverrideTestUtils.findHandlers(SampleMissingMethod.class)) + .withMessage("No static method found named message() in %s with return type %s", + SampleMissingMethod.class.getName(), String.class.getName()); + } + + @Test + void isEqualToWithSameInstance() { + TestBeanOverrideHandler handler = handlerFor(sampleField("message"), sampleMethod("message")); + assertThat(handler).isEqualTo(handler); + assertThat(handler).hasSameHashCodeAs(handler); + } + + @Test + void isEqualToWithSameMetadata() { + TestBeanOverrideHandler handler1 = handlerFor(sampleField("message"), sampleMethod("message")); + TestBeanOverrideHandler handler2 = handlerFor(sampleField("message"), sampleMethod("message")); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test + void isEqualToWithSameMetadataByNameLookupAndDifferentField() { + TestBeanOverrideHandler handler1 = handlerFor(sampleField("message3"), sampleMethod("message")); + TestBeanOverrideHandler handler2 = handlerFor(sampleField("message4"), sampleMethod("message")); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test + void isNotEqualToWithSameMetadataByTypeLookupAndDifferentField() { + TestBeanOverrideHandler handler1 = handlerFor(sampleField("message"), sampleMethod("message")); + TestBeanOverrideHandler handler2 = handlerFor(sampleField("message2"), sampleMethod("message")); + assertThat(handler1).isNotEqualTo(handler2); + } + + @Test + void isNotEqualToWithSameMetadataButDifferentBeanName() { + TestBeanOverrideHandler handler1 = handlerFor(sampleField("message"), sampleMethod("message")); + TestBeanOverrideHandler handler2 = handlerFor(sampleField("message3"), sampleMethod("message")); + assertThat(handler1).isNotEqualTo(handler2); + } + + @Test + void isNotEqualToWithSameMetadataButDifferentMethod() { + TestBeanOverrideHandler handler1 = handlerFor(sampleField("message"), sampleMethod("message")); + TestBeanOverrideHandler handler2 = handlerFor(sampleField("message"), sampleMethod("description")); + assertThat(handler1).isNotEqualTo(handler2); + } + + @Test + void isNotEqualToWithSameMetadataButDifferentAnnotations() { + TestBeanOverrideHandler handler1 = handlerFor(sampleField("message"), sampleMethod("message")); + TestBeanOverrideHandler handler2 = handlerFor(sampleField("message5"), sampleMethod("message")); + assertThat(handler1).isNotEqualTo(handler2); + } + + + private static Field sampleField(String fieldName) { + Field field = ReflectionUtils.findField(Sample.class, fieldName); + assertThat(field).isNotNull(); + return field; + } + + private static Method sampleMethod(String noArgMethodName) { + Method method = ReflectionUtils.findMethod(Sample.class, noArgMethodName); + assertThat(method).isNotNull(); + return method; + } + + private static TestBeanOverrideHandler handlerFor(Field field, Method overrideMethod) { + TestBean annotation = field.getAnnotation(TestBean.class); + String beanName = (StringUtils.hasText(annotation.name()) ? annotation.name() : null); + return new TestBeanOverrideHandler( + field, ResolvableType.forClass(field.getType()), beanName, BeanOverrideStrategy.REPLACE, overrideMethod); + } + + static class SampleOneOverride { + + @TestBean + String message; + + static String message() { + return "OK"; + } + } + + static class SampleOneOverrideWithName { + + @TestBean(name = "anotherBean") + String message; + + static String message() { + return "OK"; + } + } + + static class SampleMissingMethod { + + @TestBean + String message; + } + + + @SuppressWarnings("unused") + static class Sample { + + @TestBean + private String message; + + @TestBean + private String message2; + + @TestBean(name = "anotherBean") + private String message3; + + @TestBean(name = "anotherBean") + private String message4; + + @Qualifier("anotherBean") + @TestBean + private String message5; + + static String message() { + return "OK"; + } + + static String description() { + return message(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessorTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessorTests.java new file mode 100644 index 000000000000..c5cacb9472d9 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessorTests.java @@ -0,0 +1,253 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.lang.NonNull; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.TestBeanFactory; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link TestBeanOverrideProcessor}. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + */ +class TestBeanOverrideProcessorTests { + + private final TestBeanOverrideProcessor processor = new TestBeanOverrideProcessor(); + + @Test + void findTestBeanFactoryMethodFindsFromCandidateNames() { + Class clazz = MethodConventionTestCase.class; + Class returnType = ExampleService.class; + + Method method = this.processor.findTestBeanFactoryMethod( + clazz, returnType, "example1", "example2", "example3"); + + assertThat(method.getName()).isEqualTo("example2"); + } + + @Test + void findTestBeanFactoryMethodFindsLocalMethodWhenSubclassMethodHidesSuperclassMethod() { + Class clazz = SubTestCase.class; + Class returnType = String.class; + + Method method = this.processor.findTestBeanFactoryMethod(clazz, returnType, "factory"); + + assertThat(method).isEqualTo(ReflectionUtils.findMethod(clazz, "factory")); + } + + @Test + void findTestBeanFactoryMethodNotFound() { + Class clazz = MethodConventionTestCase.class; + Class returnType = ExampleService.class; + + assertThatIllegalStateException() + .isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, "example1", "example3")) + .withMessage("No static method found named example1() or example3() in %s with return type %s", + MethodConventionTestCase.class.getName(), ExampleService.class.getName()); + } + + @Test + void findTestBeanFactoryMethodTwoFound() { + Class clazz = MethodConventionTestCase.class; + Class returnType = ExampleService.class; + + assertThatIllegalStateException() + .isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, "example2", "example4")) + .withMessage("Found 2 competing static methods named example2() or example4() in %s with return type %s", + clazz.getName(), returnType.getName()); + } + + @Test + void findTestBeanFactoryMethodNoNameProvided() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.processor.findTestBeanFactoryMethod(MethodConventionTestCase.class, ExampleService.class)) + .withMessage("At least one candidate method name is required"); + } + + @Test + void findTestBeanFactoryMethodByFullyQualifiedName() { + Class clazz = getClass(); + Class returnType = String.class; + String methodName = TestBeanFactory.class.getName() + "#createTestMessage"; + + Method method = this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName); + + assertThat(method).isEqualTo(ReflectionUtils.findMethod(TestBeanFactory.class, "createTestMessage")); + } + + @Test + void findTestBeanFactoryMethodByFullyQualifiedNameWithNonexistentMethod() { + Class clazz = getClass(); + Class returnType = String.class; + String factoryClassName = TestBeanFactory.class.getName(); + String methodName = factoryClassName + "#bogus"; + + assertThatIllegalStateException() + .isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName)) + .withMessage("No static method found named %s in %s with return type %s", + "bogus", factoryClassName, returnType.getName()); + } + + @Test + void findTestBeanFactoryMethodByFullyQualifiedNameWithNonexistentClass() { + Class clazz = getClass(); + Class returnType = String.class; + String methodName = "org.example.Bogus#createTestBean"; + + assertThatIllegalStateException() + .isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName)) + .withMessage("Failed to load class for fully-qualified method name: %s", methodName) + .withCauseInstanceOf(ClassNotFoundException.class); + } + + @Test + void findTestBeanFactoryMethodByFullyQualifiedNameWithMissingMethodName() { + Class clazz = getClass(); + Class returnType = String.class; + String methodName = TestBeanFactory.class.getName() + "#"; + + assertThatIllegalArgumentException() + .isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName)) + .withMessage("No method name present in fully-qualified method name: %s", methodName); + } + + @Test + void findTestBeanFactoryMethodByFullyQualifiedNameWithMissingClassName() { + Class clazz = getClass(); + Class returnType = String.class; + String methodName = "#createTestBean"; + + assertThatIllegalArgumentException() + .isThrownBy(() -> this.processor.findTestBeanFactoryMethod(clazz, returnType, methodName)) + .withMessage("No class name present in fully-qualified method name: %s", methodName); + } + + @Test + void createBeanOverrideHandlerForUnknownExplicitMethod() throws Exception { + Class clazz = ExplicitMethodNameTestCase.class; + Class returnType = ExampleService.class; + Field field = clazz.getField("a"); + TestBean overrideAnnotation = field.getAnnotation(TestBean.class); + assertThat(overrideAnnotation).isNotNull(); + + assertThatIllegalStateException() + .isThrownBy(() -> this.processor.createHandler(overrideAnnotation, clazz, field)) + .withMessage("No static method found named explicit1() in %s with return type %s", + clazz.getName(), returnType.getName()); + } + + @Test + void createBeanOverrideHandlerForKnownExplicitMethod() throws Exception { + Class clazz = ExplicitMethodNameTestCase.class; + Field field = clazz.getField("b"); + TestBean overrideAnnotation = field.getAnnotation(TestBean.class); + assertThat(overrideAnnotation).isNotNull(); + + assertThat(this.processor.createHandler(overrideAnnotation, clazz, field)) + .isInstanceOf(TestBeanOverrideHandler.class); + } + + @Test + void createBeanOverrideHandlerForConventionBasedFactoryMethod() throws Exception { + Class returnType = ExampleService.class; + Class clazz = MethodConventionTestCase.class; + Field field = clazz.getField("field"); + TestBean overrideAnnotation = field.getAnnotation(TestBean.class); + assertThat(overrideAnnotation).isNotNull(); + + assertThatIllegalStateException() + .isThrownBy(() -> this.processor.createHandler(overrideAnnotation, clazz, field)) + .withMessage("No static method found named field() or someField() in %s with return type %s", + clazz.getName(), returnType.getName()); + } + + @Test + void failToCreateBeanOverrideHandlerForOtherAnnotation() throws Exception { + Class clazz = MethodConventionTestCase.class; + Field field = clazz.getField("field"); + NonNull badAnnotation = AnnotationUtils.synthesizeAnnotation(NonNull.class); + + assertThatIllegalStateException() + .isThrownBy(() -> this.processor.createHandler(badAnnotation, clazz, field)) + .withMessage("Invalid annotation passed to TestBeanOverrideProcessor: expected @TestBean" + + " on field %s.%s", field.getDeclaringClass().getName(), field.getName()); + } + + + static class MethodConventionTestCase { + + @TestBean(name = "someField") + public ExampleService field; + + ExampleService example1() { + return null; + } + + static ExampleService example2() { + return null; + } + + static ExampleService example4() { + return null; + } + } + + static class ExplicitMethodNameTestCase { + + @TestBean(methodName = "explicit1") + public ExampleService a; + + @TestBean(methodName = "explicit2") + public ExampleService b; + + static ExampleService explicit2() { + return null; + } + } + + static class BaseTestCase { + + public String field; + + static String factory() { + return null; + } + } + + static class SubTestCase extends BaseTestCase { + + // Hides factory() in superclass. + static String factory() { + return null; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanTests.java new file mode 100644 index 000000000000..08600f7c09c5 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanTests.java @@ -0,0 +1,197 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.test.context.bean.override.BeanOverrideContextCustomizerTestUtils; + +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link TestBean @TestBean}. + * + * @author Stephane Nicoll + * @author Sam Brannen + */ +public class TestBeanTests { + + @Test + void cannotOverrideBeanByNameWithNoSuchBeanName() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("anotherBean", String.class, () -> "example"); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(FailureByNameLookup.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to replace bean: there is no bean with name 'beanToOverride' \ + and type java.lang.String (as required by field 'FailureByNameLookup.example'). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type)."""); + } + + @Test + void cannotOverrideBeanByNameWithBeanOfWrongType() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("beanToOverride", Integer.class, () -> 42); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(FailureByNameLookup.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to replace bean: there is no bean with name 'beanToOverride' \ + and type java.lang.String (as required by field 'FailureByNameLookup.example'). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type)."""); + } + + @Test + void cannotOverrideBeanByTypeWithNoSuchBeanType() { + GenericApplicationContext context = new GenericApplicationContext(); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(FailureByTypeLookup.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to override bean: there are no beans of type %s (as required by field '%s.example'). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type).""", + String.class.getName(), FailureByTypeLookup.class.getSimpleName()); + } + + @Test + void cannotOverrideBeanByTypeWithTooManyBeansOfThatType() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("bean1", String.class, () -> "example1"); + context.registerBean("bean2", String.class, () -> "example2"); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(FailureByTypeLookup.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to select a bean to override: found 2 beans of type java.lang.String \ + (as required by field 'FailureByTypeLookup.example'): %s""", List.of("bean1", "bean2")); + } + + @Test + void contextCustomizerCannotBeCreatedWithMissingOverrideMethod() { + GenericApplicationContext context = new GenericApplicationContext(); + assertThatIllegalStateException() + .isThrownBy(() -> BeanOverrideContextCustomizerTestUtils.customizeApplicationContext( + FailureMissingDefaultOverrideMethod.class, context)) + .withMessage("No static method found named example() or beanToOverride() in %s with return type %s", + FailureMissingDefaultOverrideMethod.class.getName(), String.class.getName()); + } + + @Test + void contextCustomizerCannotBeCreatedWithMissingExplicitOverrideMethod() { + GenericApplicationContext context = new GenericApplicationContext(); + assertThatIllegalStateException() + .isThrownBy(() -> BeanOverrideContextCustomizerTestUtils.customizeApplicationContext( + FailureMissingExplicitOverrideMethod.class, context)) + .withMessage("No static method found named createExample() in %s with return type %s", + FailureMissingExplicitOverrideMethod.class.getName(), String.class.getName()); + } + + @Test + void contextCustomizerCannotBeCreatedWithFieldInParentAndMissingOverrideMethod() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("beanToOverride", String.class, () -> "example"); + assertThatIllegalStateException() + .isThrownBy(() -> BeanOverrideContextCustomizerTestUtils.customizeApplicationContext( + FailureOverrideInParentWithoutFactoryMethod.class, context)) + .withMessage("No static method found named beanToOverride() in %s with return type %s", + AbstractByNameLookup.class.getName(), String.class.getName()); + } + + @Test + void contextCustomizerCannotBeCreatedWithCompetingOverrideMethods() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("bean", String.class, () -> "example"); + assertThatIllegalStateException() + .isThrownBy(() -> BeanOverrideContextCustomizerTestUtils.customizeApplicationContext( + FailureCompetingOverrideMethods.class, context)) + .withMessage("Found 2 competing static methods named example() or beanToOverride() in %s with return type %s", + FailureCompetingOverrideMethods.class.getName(), String.class.getName()); + } + + + static class FailureByNameLookup { + + @TestBean(name = "beanToOverride", enforceOverride = true) + private String example; + + static String example() { + throw new IllegalStateException("Should not be called"); + } + } + + static class FailureByTypeLookup { + + @TestBean(enforceOverride = true) + private String example; + + static String example() { + throw new IllegalStateException("Should not be called"); + } + } + + static class FailureMissingDefaultOverrideMethod { + + @TestBean(name = "beanToOverride") + private String example; + + // No example() or beanToOverride() method + } + + static class FailureMissingExplicitOverrideMethod { + + @TestBean(methodName = "createExample") + private String example; + + // NO createExample() method + } + + abstract static class AbstractByNameLookup { + + @TestBean + String beanToOverride; + + // No beanToOverride() method + } + + static class FailureOverrideInParentWithoutFactoryMethod extends AbstractByNameLookup { + } + + abstract static class AbstractCompetingMethods { + + static String example() { + throw new IllegalStateException("Should not be called"); + } + } + + static class FailureCompetingOverrideMethods extends AbstractCompetingMethods { + + @TestBean(name = "beanToOverride") + String example; + + static String beanToOverride() { + throw new IllegalStateException("Should not be called"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanWithDirtiesContextBeforeMethodIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanWithDirtiesContextBeforeMethodIntegrationTests.java new file mode 100644 index 000000000000..200af51e5e1f --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanWithDirtiesContextBeforeMethodIntegrationTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import org.junit.jupiter.api.RepeatedTest; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.MethodMode; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.ExampleServiceCaller; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.springframework.test.annotation.DirtiesContext.MethodMode.BEFORE_METHOD; + +/** + * Integration tests for using {@link TestBean @TestBean} with + * {@link DirtiesContext @DirtiesContext} and {@link MethodMode#BEFORE_METHOD}. + * + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +class TestBeanWithDirtiesContextBeforeMethodIntegrationTests { + + @Autowired + ExampleServiceCaller caller; + + @TestBean + ExampleService service; + + @Autowired + ExampleService autowiredService; + + + static ExampleService service() { + return mock(); + } + + + @RepeatedTest(2) + @DirtiesContext(methodMode = BEFORE_METHOD) + void testOverride() { + assertThat(service).isSameAs(autowiredService); + + given(service.greeting()).willReturn("Spring"); + assertThat(caller.sayGreeting()).isEqualTo("I say Spring"); + } + + + @Configuration(proxyBeanMethods = false) + @Import(ExampleServiceCaller.class) + static class Config { + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanWithMultipleExistingBeansAndOneNonFallbackIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanWithMultipleExistingBeansAndOneNonFallbackIntegrationTests.java new file mode 100644 index 000000000000..037ea0d5291b --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanWithMultipleExistingBeansAndOneNonFallbackIntegrationTests.java @@ -0,0 +1,86 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.convention; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Fallback; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that {@link TestBean @TestBean} can be used to override a bean by-type + * when there are multiple candidates and only one that is not a fallback. + * + * @author Sam Brannen + * @since 6.2.1 + */ +@ExtendWith(SpringExtension.class) +@DirtiesContext +class TestBeanWithMultipleExistingBeansAndOneNonFallbackIntegrationTests { + + @TestBean + ExampleService service; + + @Autowired + List services; + + + static ExampleService service() { + return () -> "overridden"; + } + + + @Test + void test() { + assertThat(service.greeting()).isEqualTo("overridden"); + assertThat(services).extracting(ExampleService::greeting) + .containsExactlyInAnyOrder("overridden", "two", "three"); + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + ExampleService one() { + return () -> "one"; + } + + @Bean + @Fallback + ExampleService two() { + return () -> "two"; + } + + @Bean + @Fallback + ExampleService three() { + return () -> "three"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBean.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBean.java new file mode 100644 index 000000000000..08b5ae077783 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBean.java @@ -0,0 +1,64 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.easymock; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.easymock.MockType; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.test.context.bean.override.BeanOverride; + +/** + * {@code @EasyMockBean} is a field-level annotation that can be used in a test + * class to signal that a bean should be replaced with an {@link org.easymock.EasyMock + * EasyMock} mock. + * + * @author Sam Brannen + * @since 6.2 + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@BeanOverride(EasyMockBeanOverrideProcessor.class) +public @interface EasyMockBean { + + /** + * Alias for {@link #name}. + */ + @AliasFor("name") + String value() default ""; + + /** + * The name of the bean to mock. + *

    Defaults to an empty string to denote that the name of the annotated + * field should be used as the bean name. + */ + @AliasFor("value") + String name() default ""; + + /** + * The {@link MockType} to use when creating the mock. + *

    Defaults to {@link MockType#STRICT}. + */ + MockType mockType() default MockType.STRICT; + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBeanIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBeanIntegrationTests.java new file mode 100644 index 000000000000..d7bf47ae5a1f --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBeanIntegrationTests.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.easymock; + +import org.easymock.EasyMockSupport; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.reset; +import static org.springframework.test.context.TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS; + +/** + * Integration tests for {@link EasyMockBean @EasyMockBean}. + * + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +@TestExecutionListeners(listeners = EasyMockResetTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS) +@TestMethodOrder(OrderAnnotation.class) +public class EasyMockBeanIntegrationTests { + + @Autowired + ApplicationContext ctx; + + @EasyMockBean + ExampleService service; + + @Test + @Order(1) + void test1() { + assertThat(ctx.getBean("service", ExampleService.class)) + .satisfies(this::assertIsEasyMock) + .isSameAs(service); + + // Before mock setup + assertThat(service.greeting()).isNull(); + reset(service); + + // After mock setup + expect(service.greeting()).andReturn("mocked"); + replay(service); + assertThat(service.greeting()).isEqualTo("mocked"); + } + + @Test + @Order(2) + void test2() { + assertThat(ctx.getBean("service", ExampleService.class)) + .satisfies(this::assertIsEasyMock) + .isSameAs(service); + + // Before mock setup + assertThat(service.greeting()).isNull(); + reset(service); + + // After mock setup + expect(service.greeting()).andReturn("mocked"); + replay(service); + assertThat(service.greeting()).isEqualTo("mocked"); + } + + + private void assertIsEasyMock(Object obj) { + assertThat(EasyMockSupport.isAMock(obj)).as("is EasyMock mock").isTrue(); + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + ExampleService service() { + return () -> "enigma"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBeanOverrideHandler.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBeanOverrideHandler.java new file mode 100644 index 000000000000..d93fafb7837d --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBeanOverrideHandler.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.easymock; + +import java.lang.reflect.Field; + +import org.easymock.EasyMock; +import org.easymock.MockType; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.SingletonBeanRegistry; +import org.springframework.core.ResolvableType; +import org.springframework.lang.Nullable; +import org.springframework.test.context.bean.override.BeanOverrideHandler; + +import static org.springframework.test.context.bean.override.BeanOverrideStrategy.REPLACE_OR_CREATE; + +/** + * {@link BeanOverrideHandler} that provides support for {@link EasyMockBean @EasyMockBean}. + * + * @author Sam Brannen + * @since 6.2 + */ +class EasyMockBeanOverrideHandler extends BeanOverrideHandler { + + private final MockType mockType; + + + EasyMockBeanOverrideHandler(Field field, Class typeToOverride, @Nullable String beanName, + MockType mockType) { + + super(field, ResolvableType.forClass(typeToOverride), beanName, REPLACE_OR_CREATE); + this.mockType = mockType; + } + + + @Override + protected Object createOverrideInstance(String beanName, @Nullable BeanDefinition existingBeanDefinition, + @Nullable Object existingBeanInstance) { + + Class typeToMock = getBeanType().getRawClass(); + return EasyMock.mock(beanName, this.mockType, typeToMock); + } + + @Override + protected void trackOverrideInstance(Object mock, SingletonBeanRegistry singletonBeanRegistry) { + getEasyMockBeans(singletonBeanRegistry).add(mock); + } + + private static EasyMockBeans getEasyMockBeans(SingletonBeanRegistry singletonBeanRegistry) { + String beanName = EasyMockBeans.class.getName(); + EasyMockBeans easyMockBeans = null; + if (singletonBeanRegistry.containsSingleton(beanName)) { + easyMockBeans = (EasyMockBeans) singletonBeanRegistry.getSingleton(beanName); + } + if (easyMockBeans == null) { + easyMockBeans = new EasyMockBeans(); + singletonBeanRegistry.registerSingleton(beanName, easyMockBeans); + } + return easyMockBeans; + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBeanOverrideProcessor.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBeanOverrideProcessor.java new file mode 100644 index 000000000000..a8f9678d4f49 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBeanOverrideProcessor.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.easymock; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; + +import org.springframework.test.context.bean.override.BeanOverrideHandler; +import org.springframework.test.context.bean.override.BeanOverrideProcessor; +import org.springframework.util.StringUtils; + +/** + * {@link BeanOverrideProcessor} that provides support for {@link EasyMockBean @EasyMockBean}. + * + * @author Sam Brannen + * @since 6.2 + */ +class EasyMockBeanOverrideProcessor implements BeanOverrideProcessor { + + @Override + public BeanOverrideHandler createHandler(Annotation annotation, Class testClass, Field field) { + EasyMockBean easyMockBean = (EasyMockBean) annotation; + String beanName = (StringUtils.hasText(easyMockBean.name()) ? easyMockBean.name() : null); + return new EasyMockBeanOverrideHandler(field, field.getType(), beanName, easyMockBean.mockType()); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBeans.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBeans.java new file mode 100644 index 000000000000..f40925b0fd43 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockBeans.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.easymock; + +import java.util.ArrayList; +import java.util.List; + +import org.easymock.EasyMock; + +/** + * @author Sam Brannen + * @since 6.2 + */ +class EasyMockBeans { + + private final List beans = new ArrayList<>(); + + void add(Object bean) { + this.beans.add(bean); + } + + void resetAll() { + this.beans.forEach(EasyMock::reset); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockResetTestExecutionListener.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockResetTestExecutionListener.java new file mode 100644 index 000000000000..723f9ae04d60 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/easymock/EasyMockResetTestExecutionListener.java @@ -0,0 +1,70 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.easymock; + +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.Ordered; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.support.AbstractTestExecutionListener; + +/** + * {@code TestExecutionListener} that provides support for resetting mocks + * created via {@link EasyMockBean @EasyMockBean}. + * + * @author Sam Brannen + * @since 6.2 + */ +class EasyMockResetTestExecutionListener extends AbstractTestExecutionListener { + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE - 100; + } + + @Override + public void beforeTestMethod(TestContext testContext) throws Exception { + resetMocks(testContext.getApplicationContext()); + } + + @Override + public void afterTestMethod(TestContext testContext) throws Exception { + resetMocks(testContext.getApplicationContext()); + } + + private void resetMocks(ApplicationContext applicationContext) { + if (applicationContext instanceof ConfigurableApplicationContext configurableContext) { + resetMocks(configurableContext); + } + } + + private void resetMocks(ConfigurableApplicationContext applicationContext) { + ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory(); + try { + beanFactory.getBean(EasyMockBeans.class).resetAll(); + } + catch (NoSuchBeanDefinitionException ex) { + // Continue + } + if (applicationContext.getParent() != null) { + resetMocks(applicationContext.getParent()); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/example/CustomQualifier.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/CustomQualifier.java new file mode 100644 index 000000000000..390dc2bf2baa --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/CustomQualifier.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.example; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.beans.factory.annotation.Qualifier; + +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Qualifier +public @interface CustomQualifier { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/example/ExampleGenericService.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/ExampleGenericService.java new file mode 100644 index 000000000000..d81f9dfac827 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/ExampleGenericService.java @@ -0,0 +1,30 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.example; + +/** + * Example generic service interface for tests. + * + * @param the generic type + * @author Phillip Webb + * @since 6.2 + */ +public interface ExampleGenericService { + + T greeting(); + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/example/ExampleGenericServiceCaller.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/ExampleGenericServiceCaller.java new file mode 100644 index 000000000000..f91bfb0a32b6 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/ExampleGenericServiceCaller.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.example; + +/** + * Example bean that has dependencies on parameterized {@link ExampleGenericService} + * collaborators. + * + * @author Sam Brannen + * @author Phillip Webb + * @since 6.2 + */ +public record ExampleGenericServiceCaller(ExampleGenericService integerService, + ExampleGenericService stringService) { + + public String sayGreeting() { + return "I say " + this.stringService.greeting() + " " + this.integerService.greeting(); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/example/ExampleService.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/ExampleService.java new file mode 100644 index 000000000000..a86746129ad9 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/ExampleService.java @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.example; + +/** + * Example service interface for tests. + * + * @author Phillip Webb + * @since 6.2 + */ +public interface ExampleService { + + String greeting(); + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/example/ExampleServiceCaller.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/ExampleServiceCaller.java new file mode 100644 index 000000000000..6635f4105467 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/ExampleServiceCaller.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.example; + +/** + * Example bean for tests that call the {@link ExampleService}. + * + * @author Phillip Webb + * @since 6.2 + */ +public class ExampleServiceCaller { + + private final ExampleService service; + + public ExampleServiceCaller(ExampleService service) { + this.service = service; + } + + public ExampleService getService() { + return this.service; + } + + public String sayGreeting() { + return "I say " + this.service.greeting(); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/example/FailingExampleService.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/FailingExampleService.java new file mode 100644 index 000000000000..ce322fc02bc3 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/FailingExampleService.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.example; + +import org.springframework.stereotype.Service; + +/** + * An {@link ExampleService} that always throws an exception. + * + * @author Phillip Webb + */ +@Service +public class FailingExampleService implements ExampleService { + + @Override + public String greeting() { + throw new IllegalStateException("Failed"); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/example/IntegerExampleGenericService.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/IntegerExampleGenericService.java new file mode 100644 index 000000000000..fd9a7095c489 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/IntegerExampleGenericService.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.example; + +/** + * {@link ExampleGenericService} implementation for tests. + * + * @author Phillip Webb + * @author Sam Brannen + * @since 6.2 + */ +public class IntegerExampleGenericService implements ExampleGenericService { + + @Override + public Integer greeting() { + return 123; + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/example/RealExampleService.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/RealExampleService.java new file mode 100644 index 000000000000..75c9e7a8a781 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/RealExampleService.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.example; + +/** + * Example service implementation for tests. + * + * @author Phillip Webb + */ +public class RealExampleService implements ExampleService { + + private final String greeting; + + public RealExampleService(String greeting) { + this.greeting = greeting; + } + + @Override + public String greeting() { + return this.greeting; + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/example/SimpleExampleService.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/SimpleExampleService.java new file mode 100644 index 000000000000..b638aaa97b76 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/SimpleExampleService.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.example; + +/** + * Example service implementation for tests. + * + * @author Phillip Webb + * @since 6.2 + */ +public class SimpleExampleService extends RealExampleService { + + public SimpleExampleService() { + super("simple"); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/example/StringExampleGenericService.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/StringExampleGenericService.java new file mode 100644 index 000000000000..40487b3f387c --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/StringExampleGenericService.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.example; + +/** + * {@link ExampleGenericService} implementation for tests. + * + * @author Sam Brannen + * @author Phillip Webb + * @since 6.2 + */ +public record StringExampleGenericService(String greeting) implements ExampleGenericService { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/example/TestBeanFactory.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/TestBeanFactory.java new file mode 100644 index 000000000000..45329408e662 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/TestBeanFactory.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.example; + +/** + * Interface that exposes a single method that is used by bean overriding + * tests. + * + * @author Sam Brannen + * @since 6.2 + */ +public interface TestBeanFactory { + + static String createTestMessage() { + return "test"; + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/example/package-info.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/package-info.java new file mode 100644 index 000000000000..c642f01155f6 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/package-info.java @@ -0,0 +1,9 @@ +/** + * Example components for testing spring-test Bean overriding feature. + */ +@NonNullApi +@NonNullFields +package org.springframework.test.context.bean.override.example; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockResetStrategiesIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockResetStrategiesIntegrationTests.java new file mode 100644 index 000000000000..c36e98f714ea --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockResetStrategiesIntegrationTests.java @@ -0,0 +1,133 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; + +import org.springframework.test.context.bean.override.mockito.MockResetStrategiesIntegrationTests.MockVerificationExtension; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +/** + * Integration tests for {@link MockitoBean @MockitoBean} fields with different + * {@link MockReset} strategies. + * + * @author Sam Brannen + * @since 6.2.1 + * @see MockitoResetTestExecutionListenerWithoutMockitoAnnotationsIntegrationTests + * @see MockitoResetTestExecutionListenerWithMockitoBeanIntegrationTests + */ +// The MockVerificationExtension MUST be registered before the SpringExtension. +@ExtendWith(MockVerificationExtension.class) +@ExtendWith(SpringExtension.class) +@TestMethodOrder(MethodOrderer.MethodName.class) +class MockResetStrategiesIntegrationTests { + + static PuzzleService puzzleServiceNoneStaticReference; + static PuzzleService puzzleServiceBeforeStaticReference; + static PuzzleService puzzleServiceAfterStaticReference; + + + @MockitoBean(name = "puzzleServiceNone", reset = MockReset.NONE) + PuzzleService puzzleServiceNone; + + @MockitoBean(name = "puzzleServiceBefore", reset = MockReset.BEFORE) + PuzzleService puzzleServiceBefore; + + @MockitoBean(name = "puzzleServiceAfter", reset = MockReset.AFTER) + PuzzleService puzzleServiceAfter; + + + @AfterEach + void trackStaticReferences() { + puzzleServiceNoneStaticReference = this.puzzleServiceNone; + puzzleServiceBeforeStaticReference = this.puzzleServiceBefore; + puzzleServiceAfterStaticReference = this.puzzleServiceAfter; + } + + @AfterAll + static void releaseStaticReferences() { + puzzleServiceNoneStaticReference = null; + puzzleServiceBeforeStaticReference = null; + puzzleServiceAfterStaticReference = null; + } + + + @Test + void test001(TestInfo testInfo) { + assertThat(puzzleServiceNone.getAnswer()).isNull(); + assertThat(puzzleServiceBefore.getAnswer()).isNull(); + assertThat(puzzleServiceAfter.getAnswer()).isNull(); + + stubAndTestMocks(testInfo); + } + + @Test + void test002(TestInfo testInfo) { + // Should not have been reset. + assertThat(puzzleServiceNone.getAnswer()).isEqualTo("none - test001"); + + // Should have been reset. + assertThat(puzzleServiceBefore.getAnswer()).isNull(); + assertThat(puzzleServiceAfter.getAnswer()).isNull(); + + stubAndTestMocks(testInfo); + } + + private void stubAndTestMocks(TestInfo testInfo) { + String name = testInfo.getTestMethod().get().getName(); + given(puzzleServiceNone.getAnswer()).willReturn("none - " + name); + assertThat(puzzleServiceNone.getAnswer()).isEqualTo("none - " + name); + + given(puzzleServiceBefore.getAnswer()).willReturn("before - " + name); + assertThat(puzzleServiceBefore.getAnswer()).isEqualTo("before - " + name); + + given(puzzleServiceAfter.getAnswer()).willReturn("after - " + name); + assertThat(puzzleServiceAfter.getAnswer()).isEqualTo("after - " + name); + } + + interface PuzzleService { + + String getAnswer(); + } + + static class MockVerificationExtension implements AfterEachCallback { + + @Override + public void afterEach(ExtensionContext context) throws Exception { + String name = context.getRequiredTestMethod().getName(); + + // Should not have been reset. + assertThat(puzzleServiceNoneStaticReference.getAnswer()).as("puzzleServiceNone").isEqualTo("none - " + name); + assertThat(puzzleServiceBeforeStaticReference.getAnswer()).as("puzzleServiceBefore").isEqualTo("before - " + name); + + // Should have been reset. + assertThat(puzzleServiceAfterStaticReference.getAnswer()).as("puzzleServiceAfter").isNull(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanConfigurationErrorTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanConfigurationErrorTests.java new file mode 100644 index 000000000000..3090ccd10dec --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanConfigurationErrorTests.java @@ -0,0 +1,106 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.test.context.bean.override.BeanOverrideContextCustomizerTestUtils; + +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link MockitoBean @MockitoBean}. + * + * @author Stephane Nicoll + * @author Sam Brannen + */ +class MockitoBeanConfigurationErrorTests { + + @Test + void cannotOverrideBeanByNameWithNoSuchBeanName() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("anotherBean", String.class, () -> "example"); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(FailureByNameLookup.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to replace bean: there is no bean with name 'beanToOverride' \ + and type java.lang.String (as required by field 'FailureByNameLookup.example'). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type)."""); + } + + @Test + void cannotOverrideBeanByNameWithBeanOfWrongType() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("beanToOverride", Integer.class, () -> 42); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(FailureByNameLookup.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to replace bean: there is no bean with name 'beanToOverride' \ + and type java.lang.String (as required by field 'FailureByNameLookup.example'). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type)."""); + } + + @Test + void cannotOverrideBeanByTypeWithNoSuchBeanType() { + GenericApplicationContext context = new GenericApplicationContext(); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(FailureByTypeLookup.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to override bean: there are no beans of \ + type java.lang.String (as required by field 'FailureByTypeLookup.example'). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type)."""); + } + + @Test + void cannotOverrideBeanByTypeWithTooManyBeansOfThatType() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("bean1", String.class, () -> "example1"); + context.registerBean("bean2", String.class, () -> "example2"); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(FailureByTypeLookup.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to select a bean to override: found 2 beans of type java.lang.String \ + (as required by field 'FailureByTypeLookup.example'): %s""", + List.of("bean1", "bean2")); + } + + + static class FailureByTypeLookup { + + @MockitoBean(enforceOverride = true) + String example; + + } + + static class FailureByNameLookup { + + @MockitoBean(name = "beanToOverride", enforceOverride = true) + String example; + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanContextCustomizerEqualityTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanContextCustomizerEqualityTests.java new file mode 100644 index 000000000000..776fd7f387e1 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanContextCustomizerEqualityTests.java @@ -0,0 +1,158 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.Test; + +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.bean.override.BeanOverrideContextCustomizerTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Answers.RETURNS_MOCKS; + +/** + * Tests that validate the behavior of {@link MockitoBean} and + * {@link MockitoSpyBean} with the TCF context cache. + * + * @author Stephane Nicoll + */ +class MockitoBeanContextCustomizerEqualityTests { + + @Test + void contextCustomizerWithSameMockByNameInDifferentClassIsEqual() { + assertThat(customizerFor(Case1ByName.class)).isEqualTo(customizerFor(Case2ByName.class)); + } + + @Test + void contextCustomizerWithSameMockByTypeInDifferentClassIsEqual() { + assertThat(customizerFor(Case1ByType.class)).isEqualTo(customizerFor(Case2ByTypeSameFieldName.class)); + } + + @Test + void contextCustomizerWithSameMockByTypeAndDifferentFieldNamesAreNotEqual() { + assertThat(customizerFor(Case1ByType.class)).isNotEqualTo(customizerFor(Case2ByType.class)); + } + + @Test + void contextCustomizerWithSameSpyByNameInDifferentClassIsEqual() { + assertThat(customizerFor(Case4ByName.class)).isEqualTo(customizerFor(Case5ByName.class)); + } + + @Test + void contextCustomizerWithSameSpyByTypeInDifferentClassIsEqual() { + assertThat(customizerFor(Case4ByType.class)).isEqualTo(customizerFor(Case5ByTypeSameFieldName.class)); + } + + @Test + void contextCustomizerWithSameSpyByTypeAndDifferentFieldNamesAreNotEqual() { + assertThat(customizerFor(Case4ByType.class)).isNotEqualTo(customizerFor(Case5ByType.class)); + } + + @Test + void contextCustomizerWithSimilarMockButDifferentAnswersIsNotEqual() { + assertThat(customizerFor(Case1ByType.class)).isNotEqualTo(customizerFor(Case3.class)); + } + + @Test + void contextCustomizerWithMockAndSpyAreNotEqual() { + assertThat(customizerFor(Case1ByType.class)).isNotEqualTo(customizerFor(Case4ByType.class)); + } + + private ContextCustomizer customizerFor(Class testClass) { + ContextCustomizer customizer = BeanOverrideContextCustomizerTestUtils.createContextCustomizer(testClass); + assertThat(customizer).isNotNull(); + return customizer; + } + + static class Case1ByName { + + @MockitoBean("serviceBean") + private String exampleService; + + } + + static class Case1ByType { + + @MockitoBean + private String exampleService; + + } + + static class Case2ByName { + + @MockitoBean("serviceBean") + private String serviceToMock; + + } + + static class Case2ByType { + + @MockitoBean + private String serviceToMock; + + } + + static class Case2ByTypeSameFieldName { + + @MockitoBean + private String exampleService; + + } + + static class Case3 { + + @MockitoBean(answers = RETURNS_MOCKS) + private String exampleService; + + } + + static class Case4ByName { + + @MockitoSpyBean("serviceBean") + private String exampleService; + + } + + static class Case4ByType { + + @MockitoSpyBean + private String exampleService; + + } + + static class Case5ByName { + + @MockitoSpyBean("serviceBean") + private String serviceToMock; + + } + + static class Case5ByType { + + @MockitoSpyBean + private String serviceToMock; + + } + + static class Case5ByTypeSameFieldName { + + @MockitoSpyBean + private String exampleService; + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanDuplicateTypeCreationIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanDuplicateTypeCreationIntegrationTests.java new file mode 100644 index 000000000000..695e10a5b801 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanDuplicateTypeCreationIntegrationTests.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; + +/** + * Integration tests for {@link MockitoBean @MockitoBean} where duplicate mocks + * are created for the same nonexistent type, selected by-type. + * + * @author Sam Brannen + * @since 6.2.1 + * @see gh-34025 + * @see MockitoBeanDuplicateTypeReplacementIntegrationTests + * @see MockitoSpyBeanDuplicateTypeIntegrationTests + */ +@SpringJUnitConfig +public class MockitoBeanDuplicateTypeCreationIntegrationTests { + + @MockitoBean + ExampleService mock1; + + @MockitoBean + ExampleService mock2; + + @Autowired + List services; + + + @Test + void duplicateMocksShouldHaveBeenCreated() { + assertThat(services).containsExactly(mock1, mock2); + assertThat(mock1).isNotSameAs(mock2); + assertIsMock(mock1); + assertIsMock(mock2); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanDuplicateTypeReplacementIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanDuplicateTypeReplacementIntegrationTests.java new file mode 100644 index 000000000000..76124c07383a --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanDuplicateTypeReplacementIntegrationTests.java @@ -0,0 +1,97 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.context.bean.override.mockito.MockReset.AFTER; +import static org.springframework.test.context.bean.override.mockito.MockReset.BEFORE; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; + +/** + * Integration tests for {@link MockitoBean @MockitoBean} where duplicate mocks + * are created to replace the same existing bean, selected by-type. + * + *

    In other words, this test class demonstrates how one {@code @MockitoBean} + * can silently override another {@code @MockitoBean}. + * + * @author Sam Brannen + * @since 6.2.1 + * @see gh-34056 + * @see MockitoBeanDuplicateTypeCreationIntegrationTests + * @see MockitoSpyBeanDuplicateTypeIntegrationTests + */ +@SpringJUnitConfig +public class MockitoBeanDuplicateTypeReplacementIntegrationTests { + + @MockitoBean(reset = AFTER) + ExampleService mock1; + + @MockitoBean(reset = BEFORE) + ExampleService mock2; + + @Autowired + List services; + + /** + * One could argue that we would ideally expect an exception to be thrown when + * two competing mocks are created to replace the same existing bean; however, + * we currently only log a warning in such cases. + *

    This method therefore asserts the status quo in terms of behavior. + *

    And the log can be manually checked to verify that an appropriate + * warning was logged. + */ + @Test + void onlyOneMockShouldHaveBeenCreated() { + // Currently logs something similar to the following. + // + // WARN - Bean with name 'exampleService' was overridden by multiple handlers: + // [MockitoBeanOverrideHandler@5478ce1e ..., MockitoBeanOverrideHandler@5edc70ed ...] + + // Last one wins: there's only one physical mock + assertThat(services).containsExactly(mock2); + assertThat(mock1).isSameAs(mock2); + + assertIsMock(mock2); + assertThat(MockReset.get(mock2)).as("MockReset").isEqualTo(BEFORE); + + assertThat(mock2.greeting()).isNull(); + given(mock2.greeting()).willReturn("mocked"); + assertThat(mock2.greeting()).isEqualTo("mocked"); + } + + + @Configuration + static class Config { + + @Bean + ExampleService exampleService() { + return () -> "@Bean"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanForBrokenFactoryBeanIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanForBrokenFactoryBeanIntegrationTests.java new file mode 100644 index 000000000000..4f8d76b0557b --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanForBrokenFactoryBeanIntegrationTests.java @@ -0,0 +1,86 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.when; + +/** + * Test {@link MockitoBean @MockitoBean} for a {@link FactoryBean} that is + * "broken" or not able to be eagerly initialized. + * + * @author Sam Brannen + * @author Simon Baslé + */ +@SpringJUnitConfig +public class MockitoBeanForBrokenFactoryBeanIntegrationTests { + + @MockitoBean + TestBean testBean; + + + @Test + void beanReturnedByFactoryIsMocked(@Autowired TestBean autowiredTestBean) { + assertThat(autowiredTestBean).isSameAs(testBean); + + when(testBean.hello()).thenReturn("mock"); + + assertThat(testBean.hello()).isEqualTo("mock"); + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + TestFactoryBean testFactoryBean() { + return new TestFactoryBean(); + } + } + + static class TestFactoryBean implements FactoryBean { + + TestFactoryBean() { + throw new BeanCreationException("simulating missing dependencies"); + } + + @Override + public TestBean getObject() { + return () -> "prod"; + } + + @Override + public Class getObjectType() { + return TestBean.class; + } + } + + interface TestBean { + + String hello(); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanForByNameLookupIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanForByNameLookupIntegrationTests.java new file mode 100644 index 000000000000..46643178eb3a --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanForByNameLookupIntegrationTests.java @@ -0,0 +1,144 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.RealExampleService; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.mockito.MockitoAssertions; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link MockitoBean} that use by-name lookup. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +public class MockitoBeanForByNameLookupIntegrationTests { + + @MockitoBean("field") + ExampleService field; + + @MockitoBean("nonExistingBean") + ExampleService nonExisting; + + + @Test + void fieldAndRenamedFieldHaveSameOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("field")) + .isInstanceOf(ExampleService.class) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(field); + + assertThat(field.greeting()).as("mocked greeting").isNull(); + } + + @Test + void fieldIsMockedWhenNoOriginalBean(ApplicationContext ctx) { + assertThat(ctx.getBean("nonExistingBean")) + .isInstanceOf(ExampleService.class) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(nonExisting); + + assertThat(nonExisting.greeting()).as("mocked greeting").isNull(); + } + + + @Nested + @DisplayName("With @MockitoBean in enclosing class and in @Nested class") + public class MockitoBeanNestedTests { + + @Autowired + @Qualifier("field") + ExampleService localField; + + @Autowired + @Qualifier("nonExistingBean") + ExampleService localNonExisting; + + @MockitoBean("nestedField") + ExampleService nestedField; + + @MockitoBean("nestedNonExistingBean") + ExampleService nestedNonExisting; + + + @Test + void fieldAndRenamedFieldHaveSameOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("field")) + .isInstanceOf(ExampleService.class) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(localField); + + assertThat(localField.greeting()).as("mocked greeting").isNull(); + } + + @Test + void fieldIsMockedWhenNoOriginalBean(ApplicationContext ctx) { + assertThat(ctx.getBean("nonExistingBean")) + .isInstanceOf(ExampleService.class) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(localNonExisting); + + assertThat(localNonExisting.greeting()).as("mocked greeting").isNull(); + } + + @Test + void nestedFieldAndRenamedFieldHaveSameOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("nestedField")) + .isInstanceOf(ExampleService.class) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(nestedField); + } + + @Test + void nestedFieldIsMockedWhenNoOriginalBean(ApplicationContext ctx) { + assertThat(ctx.getBean("nestedNonExistingBean")) + .isInstanceOf(ExampleService.class) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(nestedNonExisting); + } + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean("field") + ExampleService bean1() { + return new RealExampleService("Hello Field"); + } + + @Bean("nestedField") + ExampleService bean2() { + return new RealExampleService("Hello Nested Field"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanForByTypeLookupIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanForByTypeLookupIntegrationTests.java new file mode 100644 index 000000000000..72f2d03435be --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanForByTypeLookupIntegrationTests.java @@ -0,0 +1,155 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.NoUniqueBeanDefinitionException; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.test.context.bean.override.example.CustomQualifier; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.RealExampleService; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.mockito.MockitoAssertions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.when; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; + +/** + * Integration tests for {@link MockitoBean} that use by-type lookup. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +public class MockitoBeanForByTypeLookupIntegrationTests { + + @MockitoBean + AnotherService serviceIsNotABean; + + @MockitoBean + ExampleService anyNameForService; + + @MockitoBean + @Qualifier("prefer") + StringBuilder ambiguous; + + @MockitoBean + @CustomQualifier + StringBuilder ambiguousMeta; + + @Test + void mockIsCreatedWhenNoCandidateIsFound() { + assertIsMock(this.serviceIsNotABean); + + when(this.serviceIsNotABean.hello()).thenReturn("Mocked hello"); + + assertThat(this.serviceIsNotABean.hello()).isEqualTo("Mocked hello"); + verify(this.serviceIsNotABean, times(1)).hello(); + verifyNoMoreInteractions(this.serviceIsNotABean); + } + + @Test + void overrideIsFoundByType(ApplicationContext ctx) { + assertThat(this.anyNameForService) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(ctx.getBean("example")) + .isSameAs(ctx.getBean(ExampleService.class)); + + when(this.anyNameForService.greeting()).thenReturn("Mocked greeting"); + + assertThat(this.anyNameForService.greeting()).isEqualTo("Mocked greeting"); + verify(this.anyNameForService, times(1)).greeting(); + verifyNoMoreInteractions(this.anyNameForService); + } + + @Test + void overrideIsFoundByTypeAndDisambiguatedByQualifier(ApplicationContext ctx) { + assertThat(this.ambiguous) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(ctx.getBean("ambiguous2")); + + assertThatExceptionOfType(NoUniqueBeanDefinitionException.class) + .isThrownBy(() -> ctx.getBean(StringBuilder.class)) + .satisfies(ex -> assertThat(ex.getBeanNamesFound()).containsOnly("ambiguous1", "ambiguous2")); + + assertThat(this.ambiguous).isEmpty(); + assertThat(this.ambiguous.substring(0)).isNull(); + verify(this.ambiguous, times(1)).length(); + verify(this.ambiguous, times(1)).substring(anyInt()); + verifyNoMoreInteractions(this.ambiguous); + } + + @Test + void overrideIsFoundByTypeAndDisambiguatedByMetaQualifier(ApplicationContext ctx) { + assertThat(this.ambiguousMeta) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(ctx.getBean("ambiguous1")); + + assertThatExceptionOfType(NoUniqueBeanDefinitionException.class) + .isThrownBy(() -> ctx.getBean(StringBuilder.class)) + .satisfies(ex -> assertThat(ex.getBeanNamesFound()).containsOnly("ambiguous1", "ambiguous2")); + + assertThat(this.ambiguousMeta).isEmpty(); + assertThat(this.ambiguousMeta.substring(0)).isNull(); + verify(this.ambiguousMeta, times(1)).length(); + verify(this.ambiguousMeta, times(1)).substring(anyInt()); + verifyNoMoreInteractions(this.ambiguousMeta); + } + + + public interface AnotherService { + + String hello(); + + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean("example") + ExampleService bean1() { + return new RealExampleService("Production hello"); + } + + @Bean("ambiguous1") + @Order(1) + @CustomQualifier + StringBuilder bean2() { + return new StringBuilder("bean2"); + } + + @Bean("ambiguous2") + @Order(2) + @Qualifier("prefer") + StringBuilder bean3() { + return new StringBuilder("bean3"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanForFactoryBeanIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanForFactoryBeanIntegrationTests.java new file mode 100644 index 000000000000..c8722a8a4a32 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanForFactoryBeanIntegrationTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.when; + +/** + * Test {@link MockitoBean @MockitoBean} for a factory bean configuration. + * + * @author Simon Baslé + */ +@SpringJUnitConfig +@TestMethodOrder(OrderAnnotation.class) +public class MockitoBeanForFactoryBeanIntegrationTests { + + @MockitoBean + private TestBean testBean; + + @Autowired + private ApplicationContext applicationContext; + + @Order(1) + @Test + void beanReturnedByFactoryIsMocked() { + TestBean bean = this.applicationContext.getBean(TestBean.class); + assertThat(bean).isSameAs(this.testBean); + + when(this.testBean.hello()).thenReturn("amock"); + assertThat(bean.hello()).isEqualTo("amock"); + + assertThat(TestFactoryBean.USED).isFalse(); + } + + @Order(2) + @Test + void beanReturnedByFactoryIsReset() { + assertThat(this.testBean.hello()).isNull(); + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + TestFactoryBean testFactoryBean() { + return new TestFactoryBean(); + } + + } + + static class TestFactoryBean implements FactoryBean { + + static final AtomicBoolean USED = new AtomicBoolean(false); + + @Override + public TestBean getObject() { + USED.set(true); + return () -> "normal"; + } + + @Override + public Class getObjectType() { + return TestBean.class; + } + } + + public interface TestBean { + + String hello(); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanManuallyRegisteredSingletonTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanManuallyRegisteredSingletonTests.java new file mode 100644 index 000000000000..4cb2d8cfecdc --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanManuallyRegisteredSingletonTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.Test; + +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.bean.override.mockito.MockitoBeanManuallyRegisteredSingletonTests.SingletonRegistrar; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.when; + +/** + * Verifies support for overriding a manually registered singleton bean with + * {@link MockitoBean @MockitoBean}. + * + * @author Andy Wilkinson + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig(initializers = SingletonRegistrar.class) +class MockitoBeanManuallyRegisteredSingletonTests { + + @MockitoBean + MessageService messageService; + + @Test + void test() { + when(messageService.getMessage()).thenReturn("override"); + assertThat(messageService.getMessage()).isEqualTo("override"); + } + + static class SingletonRegistrar implements ApplicationContextInitializer { + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + applicationContext.getBeanFactory().registerSingleton("messageService", new MessageService()); + } + } + + static class MessageService { + + String getMessage() { + return "production"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanNestedAndTypeHierarchiesTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanNestedAndTypeHierarchiesTests.java new file mode 100644 index 000000000000..72babe686745 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanNestedAndTypeHierarchiesTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; + +/** + * Integration tests for {@link MockitoBean @MockitoBean} which verify that + * {@code @MockitoBean} fields are not discovered more than once when searching + * intertwined enclosing class hierarchies and type hierarchies. + * + * @author Sam Brannen + * @since 6.2.3 + * @see gh-34324 + */ +@ExtendWith(SpringExtension.class) +class MockitoBeanNestedAndTypeHierarchiesTests { + + @Autowired + ApplicationContext enclosingContext; + + @MockitoBean + ExampleService service; + + + @Test + void topLevelTest() { + assertIsMock(service); + + // The following are prerequisites for the reported regression. + assertThat(NestedTests.class.getSuperclass()) + .isEqualTo(AbstractBaseClassForNestedTests.class); + assertThat(NestedTests.class.getEnclosingClass()) + .isEqualTo(AbstractBaseClassForNestedTests.class.getEnclosingClass()) + .isEqualTo(getClass()); + } + + + abstract class AbstractBaseClassForNestedTests { + + @Test + void nestedTest(ApplicationContext nestedContext) { + assertIsMock(service); + assertThat(enclosingContext).isSameAs(nestedContext); + } + } + + @Nested + class NestedTests extends AbstractBaseClassForNestedTests { + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanNestedTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanNestedTests.java new file mode 100644 index 000000000000..be666ef3d176 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanNestedTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +/** + * Verifies proper reset of mocks when a {@link MockitoBean @MockitoBean} field + * is declared in the enclosing class of a {@link Nested @Nested} test class. + * + * @author Andy Wilkinson + * @author Sam Brannen + * @since 6.2 + */ +@ExtendWith(SpringExtension.class) +// TODO Remove @ContextConfiguration declaration. +// @ContextConfiguration is currently required due to a bug in the TestContext framework. +// See https://github.com/spring-projects/spring-framework/issues/31456 +@ContextConfiguration +class MockitoBeanNestedTests { + + @MockitoBean + Runnable action; + + @Autowired + Task task; + + @Test + void mockWasInvokedOnce() { + task.execute(); + then(action).should().run(); + } + + @Test + void mockWasInvokedTwice() { + task.execute(); + task.execute(); + then(action).should(times(2)).run(); + } + + @Nested + class MockitoBeanFieldInEnclosingClassTests { + + @Test + void mockWasInvokedOnce() { + task.execute(); + then(action).should().run(); + } + + @Test + void mockWasInvokedTwice() { + task.execute(); + task.execute(); + then(action).should(times(2)).run(); + } + } + + record Task(Runnable action) { + + void execute() { + this.action.run(); + } + } + + @Configuration(proxyBeanMethods = false) + static class TestConfiguration { + + @Bean + Task task(Runnable action) { + return new Task(action); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandlerTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandlerTests.java new file mode 100644 index 000000000000..466bcd93e348 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandlerTests.java @@ -0,0 +1,261 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.io.Externalizable; +import java.lang.reflect.Field; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.mockito.Answers; + +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.test.context.bean.override.BeanOverrideHandler; +import org.springframework.test.context.bean.override.BeanOverrideTestUtils; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link MockitoBeanOverrideHandler}. + * + * @author Stephane Nicoll + * @author Sam Brannen + * @since 6.2 + */ +class MockitoBeanOverrideHandlerTests { + + @Test + void beanNameIsSetToNullIfAnnotationNameIsEmpty() { + List list = BeanOverrideTestUtils.findHandlers(SampleOneMock.class); + assertThat(list).singleElement().satisfies(handler -> assertThat(handler.getBeanName()).isNull()); + } + + @Test + void beanNameIsSetToAnnotationName() { + List list = BeanOverrideTestUtils.findHandlers(SampleOneMockWithName.class); + assertThat(list).singleElement().satisfies(handler -> assertThat(handler.getBeanName()).isEqualTo("anotherService")); + } + + @Test + void isEqualToWithSameInstanceFromField() { + MockitoBeanOverrideHandler handler = createHandler(sampleField("service")); + assertThat(handler).isEqualTo(handler); + assertThat(handler).hasSameHashCodeAs(handler); + } + + @Test + void isEqualToWithSameMetadataFromField() { + MockitoBeanOverrideHandler handler1 = createHandler(sampleField("service")); + MockitoBeanOverrideHandler handler2 = createHandler(sampleField("service")); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test // gh-33925 + void isEqualToWithSameInstanceFromClassLevel() { + MockitoBeanOverrideHandler handler1 = createHandler(ClassLevelStringMockByName1.class); + assertThat(handler1).isEqualTo(handler1); + assertThat(handler1).hasSameHashCodeAs(handler1); + + MockitoBeanOverrideHandler handler2 = createHandler(ClassLevelStringMockByType1.class); + assertThat(handler2).isEqualTo(handler2); + assertThat(handler2).hasSameHashCodeAs(handler2); + } + + @Test // gh-33925 + void isEqualToWithSameByNameLookupMetadataFromClassLevel() { + MockitoBeanOverrideHandler handler1 = createHandler(ClassLevelStringMockByName1.class); + MockitoBeanOverrideHandler handler2 = createHandler(ClassLevelStringMockByName2.class); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler2).isEqualTo(handler1); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test // gh-33925 + void isNotEqualToWithDifferentByNameLookupMetadataFromClassLevel() { + MockitoBeanOverrideHandler handler1 = createHandler(ClassLevelStringMockByName1.class); + MockitoBeanOverrideHandler handler2 = createHandler(ClassLevelStringMockByName3.class); + assertThat(handler1).isNotEqualTo(handler2); + assertThat(handler2).isNotEqualTo(handler1); + assertThat(handler1).doesNotHaveSameHashCodeAs(handler2); + } + + @Test // gh-33925 + void isEqualToWithSameByTypeLookupMetadataFromClassLevel() { + MockitoBeanOverrideHandler handler1 = createHandler(ClassLevelStringMockByType1.class); + MockitoBeanOverrideHandler handler2 = createHandler(ClassLevelStringMockByType2.class); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler2).isEqualTo(handler1); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test // gh-33925 + void isNotEqualToWithDifferentByTypeLookupMetadataFromClassLevel() { + MockitoBeanOverrideHandler handler1 = createHandler(ClassLevelStringMockByType1.class); + MockitoBeanOverrideHandler handler2 = createHandler(ClassLevelStringMockByType3.class); + assertThat(handler1).isNotEqualTo(handler2); + assertThat(handler2).isNotEqualTo(handler1); + assertThat(handler1).doesNotHaveSameHashCodeAs(handler2); + } + + @Test // gh-33925 + void isEqualToWithSameByNameLookupMetadataFromFieldAndClassLevel() { + MockitoBeanOverrideHandler handler1 = createHandler(sampleField("service3")); + MockitoBeanOverrideHandler handler2 = createHandler(ClassLevelStringMockByName1.class); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler2).isEqualTo(handler1); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + /** + * Since the "field name as fallback qualifier" is not available for an annotated class, + * what would seem to be "equivalent" handlers are actually not considered "equal" when + * the the lookup is "by type". + */ + @Test // gh-33925 + void isNotEqualToWithSameByTypeLookupMetadataFromFieldAndClassLevel() { + MockitoBeanOverrideHandler handler1 = createHandler(sampleField("service")); + MockitoBeanOverrideHandler handler2 = createHandler(ClassLevelStringMockByType1.class); + assertThat(handler1).isNotEqualTo(handler2); + assertThat(handler2).isNotEqualTo(handler1); + assertThat(handler1).doesNotHaveSameHashCodeAs(handler2); + } + + @Test + void isNotEqualEqualToByTypeLookupWithSameMetadataButDifferentField() { + MockitoBeanOverrideHandler handler1 = createHandler(sampleField("service")); + MockitoBeanOverrideHandler handler2 = createHandler(sampleField("service2")); + assertThat(handler1).isNotEqualTo(handler2); + } + + @Test + void isEqualEqualToByNameLookupWithSameMetadataButDifferentField() { + MockitoBeanOverrideHandler handler1 = createHandler(sampleField("service3")); + MockitoBeanOverrideHandler handler2 = createHandler(sampleField("service4")); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test + void isNotEqualToWithSameMetadataButDifferentBeanName() { + MockitoBeanOverrideHandler handler1 = createHandler(sampleField("service")); + MockitoBeanOverrideHandler handler2 = createHandler(sampleField("service3")); + assertThat(handler1).isNotEqualTo(handler2); + } + + @Test + void isNotEqualToWithSameMetadataButDifferentExtraInterfaces() { + MockitoBeanOverrideHandler handler1 = createHandler(sampleField("service")); + MockitoBeanOverrideHandler handler2 = createHandler(sampleField("service5")); + assertThat(handler1).isNotEqualTo(handler2); + } + + @Test + void isNotEqualToWithSameMetadataButDifferentAnswers() { + MockitoBeanOverrideHandler handler1 = createHandler(sampleField("service")); + MockitoBeanOverrideHandler handler2 = createHandler(sampleField("service6")); + assertThat(handler1).isNotEqualTo(handler2); + } + + @Test + void isNotEqualToWithSameMetadataButDifferentSerializableFlag() { + MockitoBeanOverrideHandler handler1 = createHandler(sampleField("service")); + MockitoBeanOverrideHandler handler2 = createHandler(sampleField("service7")); + assertThat(handler1).isNotEqualTo(handler2); + } + + + private static Field sampleField(String fieldName) { + Field field = ReflectionUtils.findField(Sample.class, fieldName); + assertThat(field).isNotNull(); + return field; + } + + private static MockitoBeanOverrideHandler createHandler(Field field) { + MockitoBean annotation = AnnotatedElementUtils.getMergedAnnotation(field, MockitoBean.class); + return new MockitoBeanOverrideHandler(field, ResolvableType.forClass(field.getType()), annotation); + } + + private MockitoBeanOverrideHandler createHandler(Class clazz) { + MockitoBean annotation = AnnotatedElementUtils.getMergedAnnotation(clazz, MockitoBean.class); + return new MockitoBeanOverrideHandler(null, ResolvableType.forClass(annotation.types()[0]), annotation); + } + + + static class SampleOneMock { + + @MockitoBean + String service; + } + + static class SampleOneMockWithName { + + @MockitoBean("anotherService") + String service; + } + + static class Sample { + + @MockitoBean + private String service; + + @MockitoBean + private String service2; + + @MockitoBean(name = "beanToMock") + private String service3; + + @MockitoBean(value = "beanToMock") + private String service4; + + @MockitoBean(extraInterfaces = Externalizable.class) + private String service5; + + @MockitoBean(answers = Answers.RETURNS_MOCKS) + private String service6; + + @MockitoBean(serializable = true) + private String service7; + } + + @MockitoBean(name = "beanToMock", types = String.class) + static class ClassLevelStringMockByName1 { + } + + @MockitoBean(name = "beanToMock", types = String.class) + static class ClassLevelStringMockByName2 { + } + + @MockitoBean(name = "otherBeanToMock", types = String.class) + static class ClassLevelStringMockByName3 { + } + + @MockitoBean(types = String.class) + static class ClassLevelStringMockByType1 { + } + + @MockitoBean(types = String.class) + static class ClassLevelStringMockByType2 { + } + + @MockitoBean(types = Integer.class) + static class ClassLevelStringMockByType3 { + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessorTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessorTests.java new file mode 100644 index 000000000000..5ac9041ac6c5 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessorTests.java @@ -0,0 +1,303 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.util.List; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.lang.Nullable; +import org.springframework.test.context.bean.override.BeanOverrideHandler; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link MockitoBeanOverrideProcessor}. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + */ +class MockitoBeanOverrideProcessorTests { + + private final MockitoBeanOverrideProcessor processor = new MockitoBeanOverrideProcessor(); + + + @Nested + class CreateHandlerTests { + + private final Field field = ReflectionUtils.findField(TestCase.class, "number"); + + + @Test + void mockAnnotationCreatesMockitoBeanOverrideHandler() { + MockitoBean annotation = AnnotationUtils.synthesizeAnnotation(MockitoBean.class); + BeanOverrideHandler object = processor.createHandler(annotation, TestCase.class, field); + + assertThat(object).isExactlyInstanceOf(MockitoBeanOverrideHandler.class); + } + + @Test + void spyAnnotationCreatesMockitoSpyBeanOverrideHandler() { + MockitoSpyBean annotation = AnnotationUtils.synthesizeAnnotation(MockitoSpyBean.class); + BeanOverrideHandler object = processor.createHandler(annotation, TestCase.class, field); + + assertThat(object).isExactlyInstanceOf(MockitoSpyBeanOverrideHandler.class); + } + + @Test + void otherAnnotationThrows() { + Annotation annotation = field.getAnnotation(Nullable.class); + + assertThatIllegalStateException() + .isThrownBy(() -> processor.createHandler(annotation, TestCase.class, field)) + .withMessage("Invalid annotation passed to MockitoBeanOverrideProcessor: expected either " + + "@MockitoBean or @MockitoSpyBean on field %s.%s", field.getDeclaringClass().getName(), + field.getName()); + } + + @Test + void typesNotSupportedAtFieldLevel() { + Field field = ReflectionUtils.findField(TestCase.class, "typesNotSupported"); + MockitoBean annotation = field.getAnnotation(MockitoBean.class); + + assertThatIllegalStateException() + .isThrownBy(() -> processor.createHandler(annotation, TestCase.class, field)) + .withMessage("The @MockitoBean 'types' attribute must be omitted when declared on a field"); + } + + + static class TestCase { + + @Nullable + @MockitoBean + @MockitoSpyBean + Integer number; + + @MockitoBean(types = Integer.class) + String typesNotSupported; + } + + @MockitoBean(name = "bogus", types = Integer.class) + static class NameNotSupportedTestCase { + } + } + + @Nested + class CreateHandlersTests { + + @Test + void otherAnnotationThrows() { + Annotation annotation = getClass().getAnnotation(Nested.class); + + assertThatIllegalStateException() + .isThrownBy(() -> processor.createHandlers(annotation, getClass())) + .withMessage("Invalid annotation passed to MockitoBeanOverrideProcessor: expected either " + + "@MockitoBean or @MockitoSpyBean on test class %s", getClass().getName()); + } + + @Nested + class MockitoBeanTests { + + @Test + void missingTypes() { + Class testClass = MissingTypesTestCase.class; + MockitoBean annotation = testClass.getAnnotation(MockitoBean.class); + + assertThatIllegalStateException() + .isThrownBy(() -> processor.createHandlers(annotation, testClass)) + .withMessage("The @MockitoBean 'types' attribute must not be empty when declared on a class"); + } + + @Test + void nameNotSupportedWithMultipleTypes() { + Class testClass = NameNotSupportedWithMultipleTypesTestCase.class; + MockitoBean annotation = testClass.getAnnotation(MockitoBean.class); + + assertThatIllegalStateException() + .isThrownBy(() -> processor.createHandlers(annotation, testClass)) + .withMessage("The @MockitoBean 'name' attribute cannot be used when mocking multiple types"); + } + + @Test + void singleMockByType() { + Class testClass = SingleMockByTypeTestCase.class; + MockitoBean annotation = testClass.getAnnotation(MockitoBean.class); + List handlers = processor.createHandlers(annotation, testClass); + + assertThat(handlers).singleElement().isInstanceOfSatisfying(MockitoBeanOverrideHandler.class, handler -> { + assertThat(handler.getField()).isNull(); + assertThat(handler.getBeanName()).isNull(); + assertThat(handler.getBeanType().resolve()).isEqualTo(Integer.class); + }); + } + + @Test + void singleMockByName() { + Class testClass = SingleMockByNameTestCase.class; + MockitoBean annotation = testClass.getAnnotation(MockitoBean.class); + List handlers = processor.createHandlers(annotation, testClass); + + assertThat(handlers).singleElement().isInstanceOfSatisfying(MockitoBeanOverrideHandler.class, handler -> { + assertThat(handler.getField()).isNull(); + assertThat(handler.getBeanName()).isEqualTo("enigma"); + assertThat(handler.getBeanType().resolve()).isEqualTo(Integer.class); + }); + } + + @Test + void multipleMocks() { + Class testClass = MultipleMocksTestCase.class; + MockitoBean annotation = testClass.getAnnotation(MockitoBean.class); + List handlers = processor.createHandlers(annotation, testClass); + + assertThat(handlers).satisfiesExactly( + handler1 -> { + assertThat(handler1.getField()).isNull(); + assertThat(handler1.getBeanName()).isNull(); + assertThat(handler1.getBeanType().resolve()).isEqualTo(Integer.class); + }, + handler2 -> { + assertThat(handler2.getField()).isNull(); + assertThat(handler2.getBeanName()).isNull(); + assertThat(handler2.getBeanType().resolve()).isEqualTo(Float.class); + } + ); + } + + + @MockitoBean + static class MissingTypesTestCase { + } + + @MockitoBean(name = "bogus", types = { Integer.class, Float.class }) + static class NameNotSupportedWithMultipleTypesTestCase { + } + + @MockitoBean(types = Integer.class) + static class SingleMockByTypeTestCase { + } + + @MockitoBean(name = "enigma", types = Integer.class) + static class SingleMockByNameTestCase { + } + + @MockitoBean(types = { Integer.class, Float.class }) + static class MultipleMocksTestCase { + } + } + + @Nested + class MockitoSpyBeanTests { + + @Test + void missingTypes() { + Class testClass = MissingTypesTestCase.class; + MockitoSpyBean annotation = testClass.getAnnotation(MockitoSpyBean.class); + + assertThatIllegalStateException() + .isThrownBy(() -> processor.createHandlers(annotation, testClass)) + .withMessage("The @MockitoSpyBean 'types' attribute must not be empty when declared on a class"); + } + + @Test + void nameNotSupportedWithMultipleTypes() { + Class testClass = NameNotSupportedWithMultipleTypesTestCase.class; + MockitoSpyBean annotation = testClass.getAnnotation(MockitoSpyBean.class); + + assertThatIllegalStateException() + .isThrownBy(() -> processor.createHandlers(annotation, testClass)) + .withMessage("The @MockitoSpyBean 'name' attribute cannot be used when mocking multiple types"); + } + + @Test + void singleSpyByType() { + Class testClass = SingleSpyByTypeTestCase.class; + MockitoSpyBean annotation = testClass.getAnnotation(MockitoSpyBean.class); + List handlers = processor.createHandlers(annotation, testClass); + + assertThat(handlers).singleElement().isInstanceOfSatisfying(MockitoSpyBeanOverrideHandler.class, handler -> { + assertThat(handler.getField()).isNull(); + assertThat(handler.getBeanName()).isNull(); + assertThat(handler.getBeanType().resolve()).isEqualTo(Integer.class); + }); + } + + @Test + void singleSpyByName() { + Class testClass = SingleSpyByNameTestCase.class; + MockitoSpyBean annotation = testClass.getAnnotation(MockitoSpyBean.class); + List handlers = processor.createHandlers(annotation, testClass); + + assertThat(handlers).singleElement().isInstanceOfSatisfying(MockitoSpyBeanOverrideHandler.class, handler -> { + assertThat(handler.getField()).isNull(); + assertThat(handler.getBeanName()).isEqualTo("enigma"); + assertThat(handler.getBeanType().resolve()).isEqualTo(Integer.class); + }); + } + + @Test + void multipleSpies() { + Class testClass = MultipleSpiesTestCase.class; + MockitoSpyBean annotation = testClass.getAnnotation(MockitoSpyBean.class); + List handlers = processor.createHandlers(annotation, testClass); + + assertThat(handlers).satisfiesExactly( + handler1 -> { + assertThat(handler1.getField()).isNull(); + assertThat(handler1.getBeanName()).isNull(); + assertThat(handler1.getBeanType().resolve()).isEqualTo(Integer.class); + }, + handler2 -> { + assertThat(handler2.getField()).isNull(); + assertThat(handler2.getBeanName()).isNull(); + assertThat(handler2.getBeanType().resolve()).isEqualTo(Float.class); + } + ); + } + + + @MockitoSpyBean + static class MissingTypesTestCase { + } + + @MockitoSpyBean(name = "bogus", types = { Integer.class, Float.class }) + static class NameNotSupportedWithMultipleTypesTestCase { + } + + @MockitoSpyBean(types = Integer.class) + static class SingleSpyByTypeTestCase { + } + + @MockitoSpyBean(name = "enigma", types = Integer.class) + static class SingleSpyByNameTestCase { + } + + @MockitoSpyBean(types = { Integer.class, Float.class }) + static class MultipleSpiesTestCase { + } + } + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverridesTestBeanIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverridesTestBeanIntegrationTests.java new file mode 100644 index 000000000000..9a52589f0ef2 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverridesTestBeanIntegrationTests.java @@ -0,0 +1,103 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.convention.TestBean; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.RealExampleService; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; + +/** + * Integration tests for Bean Overrides where a {@link MockitoBean @MockitoBean} + * overrides a {@link TestBean @TestBean} when trying to replace the same existing + * bean, selected by-type. + * + *

    In other words, this test class demonstrates how one Bean Override can + * silently override another Bean Override. + * + * @author Sam Brannen + * @since 6.2.1 + * @see gh-34056 + * @see MockitoBeanDuplicateTypeCreationIntegrationTests + * @see MockitoBeanDuplicateTypeReplacementIntegrationTests + */ +@SpringJUnitConfig +public class MockitoBeanOverridesTestBeanIntegrationTests { + + @TestBean + ExampleService testService; + + @MockitoBean + ExampleService mockService; + + @Autowired + List services; + + + static ExampleService testService() { + return new RealExampleService("@TestBean"); + } + + + /** + * One could argue that we would ideally expect an exception to be thrown when + * two competing overrides are created to replace the same existing bean; however, + * we currently only log a warning in such cases. + *

    This method therefore asserts the status quo in terms of behavior. + *

    And the log can be manually checked to verify that an appropriate + * warning was logged. + */ + @Test + void mockitoBeanShouldOverrideTestBean() { + // Currently logs something similar to the following. + // + // WARN - Bean with name 'exampleService' was overridden by multiple handlers: + // [TestBeanOverrideHandler@770beef5 ..., MockitoBeanOverrideHandler@6dd1f638 ...] + + // Last override wins... + assertThat(services).containsExactly(mockService); + assertThat(testService).isSameAs(mockService); + + assertIsMock(mockService); + + assertThat(mockService.greeting()).isNull(); + given(mockService.greeting()).willReturn("mocked"); + assertThat(mockService.greeting()).isEqualTo("mocked"); + } + + + @Configuration + static class Config { + + @Bean + ExampleService exampleService() { + return () -> "@Bean"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSuperAndSubtypeIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSuperAndSubtypeIntegrationTests.java new file mode 100644 index 000000000000..c27854b10dbe --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanSuperAndSubtypeIntegrationTests.java @@ -0,0 +1,72 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link MockitoBean @MockitoBean} where mocks are created + * for nonexistent beans for a supertype and subtype of that supertype. + * + *

    This test class is designed to reproduce scenarios that previously failed + * along the lines of the following. + * + *

    BeanNotOfRequiredTypeException: Bean named 'Subtype#0' is expected to be + * of type 'Subtype' but was actually of type 'Supertype$MockitoMock$XHb7Aspo' + * + * @author Sam Brannen + * @since 6.2.1 + * @see gh-34025 + */ +@SpringJUnitConfig +public class MockitoBeanSuperAndSubtypeIntegrationTests { + + // The declaration order of the following fields is intentional, and prior + // to fixing gh-34025 this test class consistently failed on JDK 17. + + @MockitoBean + Subtype subtype; + + @MockitoBean + Supertype supertype; + + + @Autowired + List supertypes; + + + @Test + void bothMocksShouldHaveBeenCreated() { + assertThat(supertype).isNotSameAs(subtype); + assertThat(supertypes).hasSize(2); + } + + + interface Supertype { + } + + interface Subtype extends Supertype { + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanWithResetIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanWithResetIntegrationTests.java new file mode 100644 index 000000000000..d75e512170e4 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanWithResetIntegrationTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.Nullable; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.FailingExampleService; +import org.springframework.test.context.bean.override.example.RealExampleService; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.springframework.test.context.bean.override.mockito.MockReset.BEFORE; + +/** + * Integration tests for {@link MockitoBean} that validate automatic reset + * of stubbing. + * + * @author Simon Baslé + * @since 6.2 + */ +@SpringJUnitConfig +@TestMethodOrder(OrderAnnotation.class) +public class MockitoBeanWithResetIntegrationTests { + + @MockitoBean(reset = BEFORE) + ExampleService service; + + @MockitoBean(reset = BEFORE) + FailingExampleService failingService; + + @Order(1) + @Test + void beanFirstEstablishingMock(ApplicationContext ctx) { + ExampleService mock = ctx.getBean("service", ExampleService.class); + doReturn("Mocked hello").when(mock).greeting(); + + assertThat(this.service.greeting()).isEqualTo("Mocked hello"); + } + + @Order(2) + @Test + void beanSecondEnsuringMockReset(ApplicationContext ctx) { + assertThat(ctx.getBean("service")).isNotNull().isSameAs(this.service); + + assertThat(this.service.greeting()).as("not stubbed").isNull(); + } + + @Order(3) + @Test + void factoryBeanFirstEstablishingMock(ApplicationContext ctx) { + FailingExampleService mock = ctx.getBean(FailingExampleService.class); + doReturn("Mocked hello").when(mock).greeting(); + + assertThat(this.failingService.greeting()).isEqualTo("Mocked hello"); + } + + @Order(4) + @Test + void factoryBeanSecondEnsuringMockReset(ApplicationContext ctx) { + assertThat(ctx.getBean("factory")).isNotNull().isSameAs(this.failingService); + + assertThat(this.failingService.greeting()).as("not stubbed") + .isNull(); + } + + static class FailingExampleServiceFactory implements FactoryBean { + @Nullable + @Override + public FailingExampleService getObject() { + return new FailingExampleService(); + } + + @Nullable + @Override + public Class getObjectType() { + return FailingExampleService.class; + } + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean("service") + ExampleService bean1() { + return new RealExampleService("Production hello"); + } + + @Bean("factory") + FailingExampleServiceFactory factory() { + return new FailingExampleServiceFactory(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoResetTestExecutionListenerWithMockitoBeanIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoResetTestExecutionListenerWithMockitoBeanIntegrationTests.java new file mode 100644 index 000000000000..c9a1e805eaf9 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoResetTestExecutionListenerWithMockitoBeanIntegrationTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +/** + * Integration tests for {@link MockitoResetTestExecutionListener} with a + * {@link MockitoBean @MockitoBean} field. + * + * @author Sam Brannen + * @since 6.2 + * @see MockitoResetTestExecutionListenerWithoutMockitoAnnotationsIntegrationTests + * @see MockResetStrategiesIntegrationTests + */ +class MockitoResetTestExecutionListenerWithMockitoBeanIntegrationTests + extends MockitoResetTestExecutionListenerWithoutMockitoAnnotationsIntegrationTests { + + // We declare the following to ensure that MockReset is also supported with + // @MockitoBean or @MockitoSpyBean fields present in the test class. + @MockitoBean + PuzzleService puzzleService; + + + // test001() and test002() are in the superclass. + + @Test + void test003() { + given(puzzleService.getAnswer()).willReturn("enigma"); + assertThat(puzzleService.getAnswer()).isEqualTo("enigma"); + } + + + interface PuzzleService { + + String getAnswer(); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoResetTestExecutionListenerWithoutMockitoAnnotationsIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoResetTestExecutionListenerWithoutMockitoAnnotationsIntegrationTests.java new file mode 100644 index 000000000000..80edf3e54536 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoResetTestExecutionListenerWithoutMockitoAnnotationsIntegrationTests.java @@ -0,0 +1,208 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Integration tests for {@link MockitoResetTestExecutionListener} without a + * {@link MockitoBean @MockitoBean} or {@link MockitoSpyBean @MockitoSpyBean} field. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Sam Brannen + * @since 6.2 + * @see MockitoResetTestExecutionListenerWithMockitoBeanIntegrationTests + * @see MockResetStrategiesIntegrationTests + */ +@SpringJUnitConfig +@TestMethodOrder(MethodOrderer.MethodName.class) +class MockitoResetTestExecutionListenerWithoutMockitoAnnotationsIntegrationTests { + + @Autowired + ApplicationContext context; + + + @Test + void test001() { + ExampleService nonSingletonFactoryBean = getMock("nonSingletonFactoryBean"); + + given(getMock("none").greeting()).willReturn("none"); + given(getMock("before").greeting()).willReturn("before"); + given(getMock("after").greeting()).willReturn("after"); + given(getMock("singletonFactoryBean").greeting()).willReturn("singletonFactoryBean"); + given(nonSingletonFactoryBean.greeting()).willReturn("nonSingletonFactoryBean"); + + assertThat(getMock("none").greeting()).isEqualTo("none"); + assertThat(getMock("before").greeting()).isEqualTo("before"); + assertThat(getMock("after").greeting()).isEqualTo("after"); + assertThat(getMock("singletonFactoryBean").greeting()).isEqualTo("singletonFactoryBean"); + + // The saved reference should have been mocked. + assertThat(nonSingletonFactoryBean.greeting()).isEqualTo("nonSingletonFactoryBean"); + // A new reference should have not been mocked. + assertThat(getMock("nonSingletonFactoryBean").greeting()).isNull(); + + // getMock("nonSingletonFactoryBean") has been invoked twice in this method. + assertThat(context.getBean(NonSingletonFactoryBean.class).getObjectInvocations).isEqualTo(2); + } + + @Test + void test002() { + // Should not have been reset. + assertThat(getMock("none").greeting()).isEqualTo("none"); + + // Should have been reset. + assertThat(getMock("before").greeting()).isNull(); + assertThat(getMock("after").greeting()).isNull(); + assertThat(getMock("singletonFactoryBean").greeting()).isNull(); + + // A non-singleton FactoryBean always creates a new mock instance. Thus, + // resetting is irrelevant, and the greeting should be null. + assertThat(getMock("nonSingletonFactoryBean").greeting()).isNull(); + + // getMock("nonSingletonFactoryBean") has been invoked twice in test001() + // and once in this method. + assertThat(context.getBean(NonSingletonFactoryBean.class).getObjectInvocations).isEqualTo(3); + } + + private ExampleService getMock(String name) { + return context.getBean(name, ExampleService.class); + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + ExampleService none() { + return mock(ExampleService.class); + } + + @Bean + ExampleService before() { + return mock(ExampleService.class, MockReset.before()); + } + + @Bean + ExampleService after() { + return mock(ExampleService.class, MockReset.after()); + } + + @Bean + @Lazy + ExampleService fail() { + // Spring Boot gh-5870 + throw new RuntimeException(); + } + + @Bean + BrokenFactoryBean brokenFactoryBean() { + // Spring Boot gh-7270 + return new BrokenFactoryBean(); + } + + @Bean + WorkingFactoryBean singletonFactoryBean() { + return new WorkingFactoryBean(); + } + + @Bean + NonSingletonFactoryBean nonSingletonFactoryBean() { + return new NonSingletonFactoryBean(); + } + + } + + static class BrokenFactoryBean implements FactoryBean { + + @Override + public String getObject() { + throw new IllegalStateException(); + } + + @Override + public Class getObjectType() { + return String.class; + } + + @Override + public boolean isSingleton() { + return true; + } + + } + + static class WorkingFactoryBean implements FactoryBean { + + private final ExampleService service = mock(ExampleService.class, MockReset.before()); + + @Override + public ExampleService getObject() { + return this.service; + } + + @Override + public Class getObjectType() { + return ExampleService.class; + } + + @Override + public boolean isSingleton() { + return true; + } + + } + + static class NonSingletonFactoryBean implements FactoryBean { + + private int getObjectInvocations = 0; + + @Override + public ExampleService getObject() { + this.getObjectInvocations++; + return mock(ExampleService.class, MockReset.before()); + } + + @Override + public Class getObjectType() { + return ExampleService.class; + } + + @Override + public boolean isSingleton() { + return false; + } + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanConfigurationErrorTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanConfigurationErrorTests.java new file mode 100644 index 000000000000..c1564c1dd3e1 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanConfigurationErrorTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.test.context.bean.override.BeanOverrideContextCustomizerTestUtils; + +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link MockitoSpyBean @MockitoSpyBean}. + * + * @author Stephane Nicoll + */ +class MockitoSpyBeanConfigurationErrorTests { + + @Test + void contextCustomizerCannotBeCreatedWithNoSuchBeanName() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("present", String.class, () -> "example"); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(ByNameSingleLookup.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to wrap bean: there is no bean with name 'beanToSpy' and \ + type java.lang.String (as required by field 'ByNameSingleLookup.example'). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type)."""); + } + + @Test + void contextCustomizerCannotBeCreatedWithNoSuchBeanType() { + GenericApplicationContext context = new GenericApplicationContext(); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(ByTypeSingleLookup.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to select a bean to wrap: there are no beans of type java.lang.String \ + (as required by field 'ByTypeSingleLookup.example'). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type)."""); + } + + @Test + void contextCustomizerCannotBeCreatedWithTooManyBeansOfThatType() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("bean1", String.class, () -> "example1"); + context.registerBean("bean2", String.class, () -> "example2"); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(ByTypeSingleLookup.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to select a bean to wrap: found 2 beans of type java.lang.String \ + (as required by field 'ByTypeSingleLookup.example'): %s""", + List.of("bean1", "bean2")); + } + + + static class ByTypeSingleLookup { + + @MockitoSpyBean + String example; + + } + + static class ByNameSingleLookup { + + @MockitoSpyBean("beanToSpy") + String example; + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanDuplicateTypeIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanDuplicateTypeIntegrationTests.java new file mode 100644 index 000000000000..772fab5e2c45 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanDuplicateTypeIntegrationTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.RealExampleService; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.mockito.MockitoAssertions.assertIsSpy; + +/** + * Integration tests for duplicate {@link MockitoSpyBean @MockitoSpyBean} + * declarations for the same target bean, selected by-type. + * + * @author Sam Brannen + * @since 6.2.1 + * @see gh-34056 + * @see MockitoBeanDuplicateTypeCreationIntegrationTests + * @see MockitoSpyBeanDuplicateTypeAndNameIntegrationTests + */ +@SpringJUnitConfig +public class MockitoSpyBeanDuplicateTypeIntegrationTests { + + @MockitoSpyBean + ExampleService spy1; + + @MockitoSpyBean + ExampleService spy2; + + @Autowired + List services; + + + @Test + void onlyOneSpyShouldHaveBeenCreated() { + // Currently logs something similar to the following. + // + // WARN - Bean with name 'exampleService' was overridden by multiple handlers: + // [MockitoSpyBeanOverrideHandler@1d269ed7 ..., MockitoSpyBeanOverrideHandler@437ebf59 ...] + + assertThat(services).containsExactly(spy2); + assertThat(spy1).isSameAs(spy2); + + assertIsSpy(spy2); + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + ExampleService exampleService() { + return new RealExampleService("@Bean"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanForByNameLookupIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanForByNameLookupIntegrationTests.java new file mode 100644 index 000000000000..117570a0ce83 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanForByNameLookupIntegrationTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.RealExampleService; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBeanForByNameLookupIntegrationTests.Config; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.mockito.MockitoAssertions; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link MockitoSpyBean} that use by-name lookup. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig(Config.class) +public class MockitoSpyBeanForByNameLookupIntegrationTests { + + @MockitoSpyBean("field1") + ExampleService field; + + + @Test + void fieldHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("field1")) + .isInstanceOf(ExampleService.class) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(field); + + assertThat(field.greeting()).isEqualTo("bean1"); + } + + + @Nested + @DisplayName("With @MockitoSpyBean in enclosing class and in @Nested class") + public class MockitoSpyBeanNestedTests { + + @Autowired + @Qualifier("field1") + ExampleService localField; + + @MockitoSpyBean("field2") + ExampleService nestedField; + + @Test + void fieldHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("field1")) + .isInstanceOf(ExampleService.class) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(localField); + + assertThat(localField.greeting()).isEqualTo("bean1"); + } + + @Test + void nestedFieldHasOverride(ApplicationContext ctx) { + assertThat(ctx.getBean("field2")) + .isInstanceOf(ExampleService.class) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(nestedField); + + assertThat(nestedField.greeting()).isEqualTo("bean2"); + } + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean("field1") + ExampleService bean1() { + return new RealExampleService("bean1"); + } + + @Bean("field2") + ExampleService bean2() { + return new RealExampleService("bean2"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanForByTypeLookupIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanForByTypeLookupIntegrationTests.java new file mode 100644 index 000000000000..ed07b8752fc7 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanForByTypeLookupIntegrationTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.test.context.bean.override.example.CustomQualifier; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.RealExampleService; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.mockito.MockitoAssertions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatException; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * Integration tests for {@link MockitoSpyBean} that use by-type lookup. + * + * @author Simon Baslé + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +public class MockitoSpyBeanForByTypeLookupIntegrationTests { + + @MockitoSpyBean + ExampleService anyNameForService; + + @MockitoSpyBean + @Qualifier("prefer") + StringHolder ambiguous; + + @MockitoSpyBean + @CustomQualifier + StringHolder ambiguousMeta; + + + @Test + void overrideIsFoundByType(ApplicationContext ctx) { + assertThat(this.anyNameForService) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(ctx.getBean("example")) + .isSameAs(ctx.getBean(ExampleService.class)); + + assertThat(this.anyNameForService.greeting()).isEqualTo("Production hello"); + verify(this.anyNameForService).greeting(); + verifyNoMoreInteractions(this.anyNameForService); + } + + @Test + void overrideIsFoundByTypeAndDisambiguatedByQualifier(ApplicationContext ctx) { + assertThat(this.ambiguous) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(ctx.getBean("ambiguous2")); + + assertThatException() + .isThrownBy(() -> ctx.getBean(StringHolder.class)) + .withMessageEndingWith("but found 2: ambiguous1,ambiguous2"); + + assertThat(this.ambiguous.getValue()).isEqualTo("bean3"); + assertThat(this.ambiguous.size()).isEqualTo(5); + verify(this.ambiguous).getValue(); + verify(this.ambiguous).size(); + verifyNoMoreInteractions(this.ambiguous); + } + + @Test + void overrideIsFoundByTypeAndDisambiguatedByMetaQualifier(ApplicationContext ctx) { + assertThat(this.ambiguousMeta) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(ctx.getBean("ambiguous1")); + + assertThatException() + .isThrownBy(() -> ctx.getBean(StringHolder.class)) + .withMessageEndingWith("but found 2: ambiguous1,ambiguous2"); + + assertThat(this.ambiguousMeta.getValue()).isEqualTo("bean2"); + assertThat(this.ambiguousMeta.size()).isEqualTo(5); + verify(this.ambiguousMeta).getValue(); + verify(this.ambiguousMeta).size(); + verifyNoMoreInteractions(this.ambiguousMeta); + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean("example") + ExampleService bean1() { + return new RealExampleService("Production hello"); + } + + @Bean("ambiguous1") + @Order(1) + @CustomQualifier + StringHolder bean2() { + return new StringHolder("bean2"); + } + + @Bean("ambiguous2") + @Order(2) + @Qualifier("prefer") + StringHolder bean3() { + return new StringHolder("bean3"); + } + } + + static class StringHolder { + + private final String value; + + StringHolder(String value) { + this.value = value; + } + + public String getValue() { + return this.value; + } + + public int size() { + return this.value.length(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanForFactoryBeanIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanForFactoryBeanIntegrationTests.java new file mode 100644 index 000000000000..6f9237206e07 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanForFactoryBeanIntegrationTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.mockito.MockitoBeanForFactoryBeanIntegrationTests.TestBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Test {@link MockitoSpyBean @MockitoSpyBean} for a factory bean configuration. + * + * @author Simon Baslé + */ +@SpringJUnitConfig +@TestMethodOrder(OrderAnnotation.class) +class MockitoSpyBeanForFactoryBeanIntegrationTests { + + @MockitoSpyBean + private TestBean testBean; + + @Autowired + private TestFactoryBean testFactoryBean; + + @Autowired + private ApplicationContext applicationContext; + + @Order(1) + @Test + void beanReturnedByFactoryIsSpied() { + TestBean bean = this.applicationContext.getBean(TestBean.class); + assertThat(this.testBean).as("injected same").isSameAs(bean); + assertThat(bean.hello()).isEqualTo("hi"); + verify(bean).hello(); + + doReturn("sp-hi").when(this.testBean).hello(); + + assertThat(bean.hello()).as("after stubbing").isEqualTo("sp-hi"); + verify(bean, times(2)).hello(); + } + + @Order(2) + @Test + void beanReturnedByFactoryIsReset() { + assertThat(this.testBean.hello()) + .isNotEqualTo("sp-hi"); + } + + @Test + void factoryItselfIsNotSpied() { + assertThat(this.testFactoryBean.getObject()).isNotSameAs(this.testBean); + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + TestFactoryBean testFactoryBean() { + return new TestFactoryBean(); + } + } + + static class TestBeanImpl implements TestBean { + + @Override + public String hello() { + return "hi"; + } + } + + static class TestFactoryBean implements FactoryBean { + + @Override + public TestBean getObject() { + return new TestBeanImpl(); + } + + @Override + public Class getObjectType() { + return TestBean.class; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandlerTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandlerTests.java new file mode 100644 index 000000000000..733598f3859e --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandlerTests.java @@ -0,0 +1,131 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import java.lang.reflect.Field; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.test.context.bean.override.BeanOverrideHandler; +import org.springframework.test.context.bean.override.BeanOverrideTestUtils; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link MockitoSpyBeanOverrideHandler}. + * + * @author Stephane Nicoll + */ +class MockitoSpyBeanOverrideHandlerTests { + + @Test + void beanNameIsSetToNullIfAnnotationNameIsEmpty() { + List list = BeanOverrideTestUtils.findHandlers(SampleOneSpy.class); + assertThat(list).singleElement().satisfies(handler -> assertThat(handler.getBeanName()).isNull()); + } + + @Test + void beanNameIsSetToAnnotationName() { + List list = BeanOverrideTestUtils.findHandlers(SampleOneSpyWithName.class); + assertThat(list).singleElement().satisfies(handler -> assertThat(handler.getBeanName()).isEqualTo("anotherService")); + } + + @Test + void isEqualToWithSameInstance() { + MockitoSpyBeanOverrideHandler handler = handlerFor("service"); + assertThat(handler).isEqualTo(handler); + assertThat(handler).hasSameHashCodeAs(handler); + } + + @Test + void isEqualToWithSameMetadata() { + MockitoSpyBeanOverrideHandler handler1 = handlerFor("service"); + MockitoSpyBeanOverrideHandler handler2 = handlerFor("service"); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test + void isNotEqualToByTypeLookupWithSameMetadataButDifferentField() { + assertThat(handlerFor("service")).isNotEqualTo(handlerFor("service2")); + } + + @Test + void isEqualToByNameLookupWithSameMetadataButDifferentField() { + MockitoSpyBeanOverrideHandler handler1 = handlerFor("service3"); + MockitoSpyBeanOverrideHandler handler2 = handlerFor("service4"); + assertThat(handler1).isEqualTo(handler2); + assertThat(handler1).hasSameHashCodeAs(handler2); + } + + @Test + void isNotEqualToWithSameMetadataButDifferentBeanName() { + assertThat(handlerFor("service")).isNotEqualTo(handlerFor("service3")); + } + + @Test + void isNotEqualToWithSameMetadataButDifferentReset() { + assertThat(handlerFor("service")).isNotEqualTo(handlerFor("service5")); + } + + + private static MockitoSpyBeanOverrideHandler handlerFor(String fieldName) { + Field field = ReflectionUtils.findField(Sample.class, fieldName); + assertThat(field).isNotNull(); + MockitoSpyBean annotation = AnnotatedElementUtils.getMergedAnnotation(field, MockitoSpyBean.class); + return new MockitoSpyBeanOverrideHandler(field, ResolvableType.forClass(field.getType()), annotation); + } + + + static class SampleOneSpy { + + @MockitoSpyBean + String service; + + } + + static class SampleOneSpyWithName { + + @MockitoSpyBean("anotherService") + String service; + + } + + static class Sample { + + @MockitoSpyBean + private String service; + + @MockitoSpyBean + private String service2; + + @MockitoSpyBean(name = "beanToSpy") + private String service3; + + @MockitoSpyBean(value = "beanToSpy") + private String service4; + + @MockitoSpyBean(reset = MockReset.BEFORE) + private String service5; + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanWithResetIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanWithResetIntegrationTests.java new file mode 100644 index 000000000000..cecde1de0dc4 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanWithResetIntegrationTests.java @@ -0,0 +1,123 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.FailingExampleService; +import org.springframework.test.context.bean.override.example.RealExampleService; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.Mockito.doReturn; +import static org.springframework.test.context.bean.override.mockito.MockReset.BEFORE; + +/** + * Integration tests for {@link MockitoSpyBean} that validate automatic reset + * of stubbing. + * + * @author Simon Baslé + * @since 6.2 + */ +@SpringJUnitConfig +@TestMethodOrder(OrderAnnotation.class) +public class MockitoSpyBeanWithResetIntegrationTests { + + @MockitoSpyBean(reset = BEFORE) + ExampleService service; + + @MockitoSpyBean(name = "failingExampleServiceFactory", reset = BEFORE) + FailingExampleService failingService; + + + @Order(1) + @Test + void beanFirstEstablishingStub(ApplicationContext ctx) { + ExampleService spy = ctx.getBean("service", ExampleService.class); + doReturn("Stubbed hello").when(spy).greeting(); + + assertThat(this.service.greeting()).isEqualTo("Stubbed hello"); + } + + @Order(2) + @Test + void beanSecondEnsuringStubReset(ApplicationContext ctx) { + assertThat(ctx.getBean("service")).isNotNull().isSameAs(this.service); + + assertThat(this.service.greeting()).as("not stubbed") + .isEqualTo("Production hello"); + } + + @Order(3) + @Test + void factoryBeanFirstEstablishingStub(ApplicationContext ctx) { + FailingExampleService spy = ctx.getBean(FailingExampleService.class); + doReturn("Stubbed hello").when(spy).greeting(); + + assertThat(this.failingService.greeting()).isEqualTo("Stubbed hello"); + } + + @Order(4) + @Test + void factoryBeanSecondEnsuringStubReset(ApplicationContext ctx) { + assertThat(ctx.getBean("failingExampleServiceFactory")) + .isNotNull() + .isSameAs(this.failingService); + + assertThatIllegalStateException().isThrownBy(this.failingService::greeting) + .as("not stubbed") + .withMessage("Failed"); + } + + + static class FailingExampleServiceFactory implements FactoryBean { + + @Override + public FailingExampleService getObject() { + return new FailingExampleService(); + } + + @Override + public Class getObjectType() { + return FailingExampleService.class; + } + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + ExampleService service() { + return new RealExampleService("Production hello"); + } + + @Bean + FailingExampleServiceFactory failingExampleServiceFactory() { + return new FailingExampleServiceFactory(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/SpringMockResolverIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/SpringMockResolverIntegrationTests.java new file mode 100644 index 000000000000..5ebb2bfc8aa7 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/SpringMockResolverIntegrationTests.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.Test; +import org.mockito.internal.configuration.plugins.Plugins; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link SpringMockResolver}. + * + * @author Andy Wilkinson + * @since 6.2 + * @see SpringMockResolverTests + */ +class SpringMockResolverIntegrationTests { + + @Test + void customMockResolverIsRegisteredWithMockito() { + assertThat(Plugins.getMockResolvers()).hasOnlyElementsOfType(SpringMockResolver.class); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/SpringMockResolverTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/SpringMockResolverTests.java new file mode 100644 index 000000000000..131d74d43120 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/SpringMockResolverTests.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito; + +import org.junit.jupiter.api.Test; + +import org.springframework.aop.SpringProxy; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.aop.target.HotSwappableTargetSource; +import org.springframework.aop.target.SingletonTargetSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link SpringMockResolver}. + * + * @author Moritz Halbritter + * @author Sam Brannen + * @since 6.2 + * @see SpringMockResolverIntegrationTests + */ +class SpringMockResolverTests { + + @Test + void staticTarget() { + MyServiceImpl myService = new MyServiceImpl(); + MyService proxy = ProxyFactory.getProxy(MyService.class, new SingletonTargetSource(myService)); + Object target = new SpringMockResolver().resolve(proxy); + assertThat(target).isInstanceOf(MyServiceImpl.class); + } + + @Test + void nonStaticTarget() { + MyServiceImpl myService = new MyServiceImpl(); + MyService proxy = ProxyFactory.getProxy(MyService.class, new HotSwappableTargetSource(myService)); + Object target = new SpringMockResolver().resolve(proxy); + assertThat(target).isInstanceOf(SpringProxy.class); + } + + + private interface MyService { + } + + private static final class MyServiceImpl implements MyService { + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/AbstractMockitoBeanAndGenericsIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/AbstractMockitoBeanAndGenericsIntegrationTests.java new file mode 100644 index 000000000000..87994ac6a827 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/AbstractMockitoBeanAndGenericsIntegrationTests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.integration.AbstractMockitoBeanAndGenericsIntegrationTests.Something; +import org.springframework.test.context.bean.override.mockito.integration.AbstractMockitoBeanAndGenericsIntegrationTests.Thing; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Abstract base class for tests for {@link MockitoBean @MockitoBean} with generics. + * + * @param type of thing + * @param type of something + * @author Madhura Bhave + * @author Sam Brannen + * @since 6.2 + * @see MockitoBeanAndGenericsIntegrationTests + */ +@SpringJUnitConfig +abstract class AbstractMockitoBeanAndGenericsIntegrationTests, S extends Something> { + + @Autowired + T thing; + + @MockitoBean + S something; + + + static class Something { + String speak() { + return "Hi"; + } + } + + static class SomethingImpl extends Something { + } + + abstract static class Thing { + + @Autowired + private S something; + + S getSomething() { + return this.something; + } + } + + static class ThingImpl extends Thing { + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + ThingImpl thing() { + return new ThingImpl(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndAsyncInterfaceMethodIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndAsyncInterfaceMethodIntegrationTests.java new file mode 100644 index 000000000000..617238f759a1 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndAsyncInterfaceMethodIntegrationTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static java.util.concurrent.CompletableFuture.completedFuture; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; + +/** + * Tests for {@link MockitoBean @MockitoBean} where the mocked interface has an + * {@link Async @Async} method. + * + * @author Sam Brannen + * @author Andy Wilkinson + * @since 6.2 + */ +@ExtendWith(SpringExtension.class) +public class MockitoBeanAndAsyncInterfaceMethodIntegrationTests { + + @MockitoBean + Transformer transformer; + + @Autowired + MyService service; + + + @Test + void mockedMethodsAreNotAsync() throws Exception { + assertThat(AopUtils.isAopProxy(transformer)).as("is Spring AOP proxy").isFalse(); + assertIsMock(transformer); + + given(transformer.transform("foo")).willReturn(completedFuture("bar")); + assertThat(service.transform("foo")).isEqualTo("result: bar"); + } + + + interface Transformer { + + @Async + CompletableFuture transform(String input); + } + + record MyService(Transformer transformer) { + + String transform(String input) throws Exception { + return "result: " + this.transformer.transform(input).get(); + } + } + + @Configuration(proxyBeanMethods = false) + @EnableAsync + static class Config { + + @Bean + Transformer transformer() { + return input -> completedFuture(input.toUpperCase()); + } + + @Bean + MyService myService(Transformer transformer) { + return new MyService(transformer); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndContextHierarchyParentIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndContextHierarchyParentIntegrationTests.java new file mode 100644 index 000000000000..00950dcd03fd --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndContextHierarchyParentIntegrationTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ContextHierarchy; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.ExampleServiceCaller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +/** + * Tests which verify that {@link MockitoBean @MockitoBean} can be used within a + * {@link ContextHierarchy @ContextHierarchy}. + * + * @author Sam Brannen + * @author Phillip Webb + * @since 6.2 + * @see MockitoSpyBeanAndContextHierarchyChildIntegrationTests + */ +@SpringJUnitConfig +public class MockitoBeanAndContextHierarchyParentIntegrationTests { + + @MockitoBean + ExampleService service; + + @Autowired + ApplicationContext context; + + @BeforeEach + void configureServiceMock() { + given(service.greeting()).willReturn("mock"); + } + + @Test + void test() { + assertThat(context.getBeanNamesForType(ExampleService.class)).hasSize(1); + assertThat(context.getBeanNamesForType(ExampleServiceCaller.class)).isEmpty(); + + assertThat(service.greeting()).isEqualTo("mock"); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndGenericsIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndGenericsIntegrationTests.java new file mode 100644 index 000000000000..50f7d09e6634 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndGenericsIntegrationTests.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; + +import org.springframework.test.context.bean.override.mockito.integration.AbstractMockitoBeanAndGenericsIntegrationTests.SomethingImpl; +import org.springframework.test.context.bean.override.mockito.integration.AbstractMockitoBeanAndGenericsIntegrationTests.ThingImpl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.when; + +/** + * Concrete implementation of {@link AbstractMockitoBeanAndGenericsIntegrationTests}. + * + * @author Madhura Bhave + * @author Sam Brannen + * @since 6.2 + */ +class MockitoBeanAndGenericsIntegrationTests extends AbstractMockitoBeanAndGenericsIntegrationTests { + + @Test + void mockitoBeanShouldResolveConcreteType() { + assertThat(something).isExactlyInstanceOf(SomethingImpl.class); + + when(something.speak()).thenReturn("Hola"); + assertThat(thing.getSomething().speak()).isEqualTo("Hola"); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndScopedProxyIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndScopedProxyIntegrationTests.java new file mode 100644 index 000000000000..1dad78d84346 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndScopedProxyIntegrationTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.ExampleServiceCaller; +import org.springframework.test.context.bean.override.example.FailingExampleService; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +/** + * Tests for {@link MockitoBean @MockitoBean} used in combination with scoped-proxy + * targets. + * + * @author Phillip Webb + * @author Sam Brannen + * @since 6.2 + * @see gh-5724 + */ +@ExtendWith(SpringExtension.class) +public class MockitoBeanAndScopedProxyIntegrationTests { + + @MockitoBean + // The ExampleService mock should replace the scoped-proxy FailingExampleService + // created in the @Configuration class. + ExampleService service; + + @Autowired + ExampleServiceCaller serviceCaller; + + + @BeforeEach + void configureServiceMock() { + given(service.greeting()).willReturn("mock"); + } + + @Test + void testMocking() { + assertThat(serviceCaller.sayGreeting()).isEqualTo("I say mock"); + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS) + ExampleService exampleService() { + return new FailingExampleService(); + } + + @Bean + ExampleServiceCaller serviceCaller(ExampleService service) { + return new ExampleServiceCaller(service); + } + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndSpringAopProxyIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndSpringAopProxyIntegrationTests.java new file mode 100644 index 000000000000..edfe9c595418 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndSpringAopProxyIntegrationTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.aop.support.AopUtils; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.cache.interceptor.CacheResolver; +import org.springframework.cache.interceptor.SimpleCacheResolver; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; + +/** + * Tests for {@link MockitoBean @MockitoBean} used in combination with Spring AOP. + * + * @author Sam Brannen + * @author Phillip Webb + * @since 6.2 + * @see 5837 + * @see MockitoSpyBeanAndSpringAopProxyIntegrationTests + */ +@ExtendWith(SpringExtension.class) +class MockitoBeanAndSpringAopProxyIntegrationTests { + + @MockitoBean + DateService dateService; + + + /** + * Since the {@code BeanOverrideBeanFactoryPostProcessor} always registers a + * manual singleton for a {@code @MockitoBean} mock, the mock that ends up + * in the application context should not be proxied by Spring AOP (since + * BeanPostProcessors are never applied to manually registered singletons). + * + *

    In other words, this test effectively verifies that the mock is a + * standard Mockito mock which does not have + * {@link Cacheable @Cacheable} applied to it. + */ + @RepeatedTest(2) + void mockShouldNotBeAnAopProxy() { + assertThat(AopUtils.isAopProxy(dateService)).as("is Spring AOP proxy").isFalse(); + assertIsMock(dateService); + + given(dateService.getDate(false)).willReturn(1L); + Long date = dateService.getDate(false); + assertThat(date).isOne(); + + given(dateService.getDate(false)).willReturn(2L); + date = dateService.getDate(false); + assertThat(date).isEqualTo(2L); + + verify(dateService, times(2)).getDate(false); + verify(dateService, times(2)).getDate(eq(false)); + verify(dateService, times(2)).getDate(anyBoolean()); + } + + + @Configuration(proxyBeanMethods = false) + @EnableCaching(proxyTargetClass = true) + @Import(DateService.class) + static class Config { + + @Bean + CacheResolver cacheResolver(CacheManager cacheManager) { + return new SimpleCacheResolver(cacheManager); + } + + @Bean + ConcurrentMapCacheManager cacheManager() { + return new ConcurrentMapCacheManager("test"); + } + } + + static class DateService { + + @Cacheable("test") + Long getDate(boolean argument) { + return System.nanoTime(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndSpringMethodRuleWithRepeatJUnit4IntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndSpringMethodRuleWithRepeatJUnit4IntegrationTests.java new file mode 100644 index 000000000000..a58e75041748 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanAndSpringMethodRuleWithRepeatJUnit4IntegrationTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; + +import org.springframework.test.annotation.Repeat; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit4.rules.SpringMethodRule; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.when; + +/** + * Tests for {@link MockitoBean @MockitoBean}, {@link SpringMethodRule}, and + * {@link Repeat @Repeat} with JUnit 4. + * + * @author Andy Wilkinson + * @author Sam Brannen + * @see gh-27693 + */ +public class MockitoBeanAndSpringMethodRuleWithRepeatJUnit4IntegrationTests { + + private static int invocations; + + @Rule + public final SpringMethodRule springMethodRule = new SpringMethodRule(); + + @MockitoBean + Service service; + + @BeforeClass + public static void beforeClass() { + invocations = 0; + } + + @Test + @Repeat(2) + public void repeatedTest() { + assertThat(service.greeting()).as("mock should have been reset").isNull(); + + when(service.greeting()).thenReturn("test"); + assertThat(service.greeting()).isEqualTo("test"); + + invocations++; + } + + @AfterClass + public static void afterClass() { + assertThat(invocations).isEqualTo(2); + } + + + interface Service { + + String greeting(); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanUsedDuringApplicationContextRefreshIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanUsedDuringApplicationContextRefreshIntegrationTests.java new file mode 100644 index 000000000000..611ae207ab35 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanUsedDuringApplicationContextRefreshIntegrationTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; + +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.integration.MockitoBeanUsedDuringApplicationContextRefreshIntegrationTests.ContextRefreshedEventListener; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; +import static org.springframework.test.mockito.MockitoAssertions.assertIsNotSpy; + +/** + * Integration tests for {@link MockitoBean @MockitoBean} used during + * {@code ApplicationContext} refresh. + * + * @author Sam Brannen + * @author Yanming Zhou + * @since 6.2.1 + * @see MockitoSpyBeanUsedDuringApplicationContextRefreshIntegrationTests + */ +@SpringJUnitConfig(ContextRefreshedEventListener.class) +class MockitoBeanUsedDuringApplicationContextRefreshIntegrationTests { + + @MockitoBean + ContextRefreshedEventProcessor eventProcessor; + + + @Test + void test() { + assertIsMock(eventProcessor); + assertIsNotSpy(eventProcessor); + + // Ensure that the mock was invoked during ApplicationContext refresh + // and has not been reset in the interim. + then(eventProcessor).should().process(any(ContextRefreshedEvent.class)); + } + + + interface ContextRefreshedEventProcessor { + void process(ContextRefreshedEvent event); + } + + // MUST be annotated with @Component, due to EventListenerMethodProcessor.isSpringContainerClass(). + @Component + record ContextRefreshedEventListener(ContextRefreshedEventProcessor contextRefreshedEventProcessor) { + + @EventListener + void onApplicationEvent(ContextRefreshedEvent event) { + this.contextRefreshedEventProcessor.process(event); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithDirtiesContextBeforeMethodIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithDirtiesContextBeforeMethodIntegrationTests.java new file mode 100644 index 000000000000..219564511267 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithDirtiesContextBeforeMethodIntegrationTests.java @@ -0,0 +1,72 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.RepeatedTest; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.MethodMode; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.ExampleServiceCaller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.annotation.DirtiesContext.MethodMode.BEFORE_METHOD; + +/** + * Integration tests for using {@link MockitoBean @MockitoBean} with + * {@link DirtiesContext @DirtiesContext} and {@link MethodMode#BEFORE_METHOD}. + * + * @author Andy Wilkinson + * @author Sam Brannen + * @since 6.2 + * @see MockitoSpyBeanWithDirtiesContextBeforeMethodIntegrationTests + */ +@SpringJUnitConfig +class MockitoBeanWithDirtiesContextBeforeMethodIntegrationTests { + + @Autowired + ExampleServiceCaller caller; + + @MockitoBean + ExampleService service; + + @Autowired + ExampleService autowiredService; + + + @RepeatedTest(2) + @DirtiesContext(methodMode = BEFORE_METHOD) + void testMocking() { + assertThat(service).isSameAs(autowiredService); + + given(service.greeting()).willReturn("Spring"); + assertThat(caller.sayGreeting()).isEqualTo("I say Spring"); + } + + + @Configuration(proxyBeanMethods = false) + @Import(ExampleServiceCaller.class) + static class Config { + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithGenericsOnTestFieldForNewBeanIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithGenericsOnTestFieldForNewBeanIntegrationTests.java new file mode 100644 index 000000000000..eae4a87b7f99 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithGenericsOnTestFieldForNewBeanIntegrationTests.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.example.ExampleGenericService; +import org.springframework.test.context.bean.override.example.ExampleGenericServiceCaller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +/** + * Tests that {@link MockitoBean @MockitoBean} on fields with generics can be used + * to inject new mock instances. + * + * @author Phillip Webb + * @author Sam Brannen + * @since 6.2 + * @see MockitoSpyBeanWithGenericsOnTestFieldForExistingGenericBeanIntegrationTests + */ +@SpringJUnitConfig +class MockitoBeanWithGenericsOnTestFieldForNewBeanIntegrationTests { + + @MockitoBean + ExampleGenericService stringService; + + @MockitoBean + ExampleGenericService integerService; + + @Autowired + ExampleGenericServiceCaller caller; + + + @Test + void testMocking() { + given(stringService.greeting()).willReturn("Hello"); + given(integerService.greeting()).willReturn(42); + assertThat(caller.sayGreeting()).isEqualTo("I say Hello 42"); + } + + + @Configuration(proxyBeanMethods = false) + @Import(ExampleGenericServiceCaller.class) + static class Config { + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests.java new file mode 100644 index 000000000000..922b988b6628 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.example.ExampleGenericServiceCaller; +import org.springframework.test.context.bean.override.example.IntegerExampleGenericService; +import org.springframework.test.context.bean.override.example.StringExampleGenericService; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; +import static org.springframework.test.mockito.MockitoAssertions.assertMockName; + +/** + * Tests that {@link MockitoBean @MockitoBean} can be used to mock a bean when + * there are multiple candidates and an explicit bean name is supplied to select + * one of the candidates. + * + * @author Sam Brannen + * @author Phillip Webb + * @since 6.2 + * @see MockitoBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests + * @see MockitoBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests + */ +@SpringJUnitConfig +class MockitoBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests { + + @MockitoBean("stringService") + StringExampleGenericService mock; + + @Autowired + ExampleGenericServiceCaller caller; + + + @Test + void test() { + assertIsMock(mock); + assertMockName(mock, "stringService"); + + given(mock.greeting()).willReturn("mocked"); + assertThat(caller.sayGreeting()).isEqualTo("I say mocked 123"); + then(mock).should().greeting(); + } + + + @Configuration(proxyBeanMethods = false) + @Import({ ExampleGenericServiceCaller.class, IntegerExampleGenericService.class }) + static class Config { + + @Bean + StringExampleGenericService one() { + return new StringExampleGenericService("one"); + } + + @Bean + // "stringService" matches the constructor argument name in ExampleGenericServiceCaller + StringExampleGenericService stringService() { + return new StringExampleGenericService("two"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests.java new file mode 100644 index 000000000000..b7f0d54315b3 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.example.ExampleGenericServiceCaller; +import org.springframework.test.context.bean.override.example.IntegerExampleGenericService; +import org.springframework.test.context.bean.override.example.StringExampleGenericService; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; +import static org.springframework.test.mockito.MockitoAssertions.assertMockName; + +/** + * Tests that {@link MockitoBean @MockitoBean} can be used to mock a bean when + * there are multiple candidates and a {@link Qualifier @Qualifier} is supplied + * to select one of the candidates. + * + * @author Sam Brannen + * @author Phillip Webb + * @since 6.2 + * @see MockitoBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests + * @see MockitoBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests + */ +@SpringJUnitConfig +class MockitoBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests { + + @Qualifier("stringService") + @MockitoBean + StringExampleGenericService mock; + + @Autowired + ExampleGenericServiceCaller caller; + + + @Test + void test() { + assertIsMock(mock); + assertMockName(mock, "stringService"); + + given(mock.greeting()).willReturn("mocked"); + assertThat(caller.sayGreeting()).isEqualTo("I say mocked 123"); + then(mock).should().greeting(); + } + + + @Configuration(proxyBeanMethods = false) + @Import({ ExampleGenericServiceCaller.class, IntegerExampleGenericService.class }) + static class Config { + + @Bean + StringExampleGenericService one() { + return new StringExampleGenericService("one"); + } + + @Bean + // "stringService" matches the constructor argument name in ExampleGenericServiceCaller + StringExampleGenericService stringService() { + return new StringExampleGenericService("two"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithMultipleExistingBeansAndOnePrimaryAndOneConflictingQualifierIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithMultipleExistingBeansAndOnePrimaryAndOneConflictingQualifierIntegrationTests.java new file mode 100644 index 000000000000..d29657a351ec --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithMultipleExistingBeansAndOnePrimaryAndOneConflictingQualifierIntegrationTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.mockito.BDDMockito.then; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; +import static org.springframework.test.mockito.MockitoAssertions.assertIsNotMock; + +/** + * Tests that {@link MockitoBean @MockitoBean} can be used to mock a bean when + * there are multiple candidates; one is primary; and the field name matches + * the name of a candidate which is not the primary candidate. + * + * @author Sam Brannen + * @since 6.2.3 + * @see MockitoBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests + * @see MockitoBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests + * @see MockitoBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests + */ +@ExtendWith(SpringExtension.class) +class MockitoBeanWithMultipleExistingBeansAndOnePrimaryAndOneConflictingQualifierIntegrationTests { + + // The name of this field must be "baseService" to match the name of the non-primary candidate. + @MockitoBean + BaseService baseService; + + @Autowired + Client client; + + + @Test // gh-34374 + void test(ApplicationContext context) { + assertIsMock(baseService, "baseService field"); + assertIsMock(context.getBean("extendedService"), "extendedService bean"); + assertIsNotMock(context.getBean("baseService"), "baseService bean"); + + client.callService(); + + then(baseService).should().doSomething(); + } + + + @Configuration(proxyBeanMethods = false) + @Import({ BaseService.class, ExtendedService.class, Client.class }) + static class Config { + } + + @Component("baseService") + static class BaseService { + + public void doSomething() { + } + } + + @Primary + @Component("extendedService") + static class ExtendedService extends BaseService { + } + + @Component("client") + static class Client { + + private final BaseService baseService; + + public Client(BaseService baseService) { + this.baseService = baseService; + } + + public void callService() { + this.baseService.doSomething(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests.java new file mode 100644 index 000000000000..b4c8a245b3dd --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.bean.override.example.ExampleGenericServiceCaller; +import org.springframework.test.context.bean.override.example.IntegerExampleGenericService; +import org.springframework.test.context.bean.override.example.StringExampleGenericService; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; +import static org.springframework.test.mockito.MockitoAssertions.assertMockName; + +/** + * Tests that {@link MockitoBean @MockitoBean} can be used to mock a bean when + * there are multiple candidates and one is primary. + * + * @author Sam Brannen + * @author Phillip Webb + * @since 6.2 + * @see MockitoBeanWithMultipleExistingBeansAndOnePrimaryAndOneConflictingQualifierIntegrationTests + * @see MockitoBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests + * @see MockitoBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests + */ +@ExtendWith(SpringExtension.class) +class MockitoBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests { + + @MockitoBean + StringExampleGenericService mock; + + @Autowired + ExampleGenericServiceCaller caller; + + + @Test + void test() { + assertIsMock(mock); + assertMockName(mock, "two"); + + given(mock.greeting()).willReturn("mocked"); + assertThat(caller.sayGreeting()).isEqualTo("I say mocked 123"); + then(mock).should().greeting(); + } + + + @Configuration(proxyBeanMethods = false) + @Import({ ExampleGenericServiceCaller.class, IntegerExampleGenericService.class }) + static class Config { + + @Bean + StringExampleGenericService one() { + return new StringExampleGenericService("one"); + } + + @Bean + @Primary + StringExampleGenericService two() { + return new StringExampleGenericService("two"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanAndCircularDependenciesWithAutowiredSettersIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanAndCircularDependenciesWithAutowiredSettersIntegrationTests.java new file mode 100644 index 000000000000..b469f9aa742e --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanAndCircularDependenciesWithAutowiredSettersIntegrationTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.aot.DisabledInAotMode; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.mockito.BDDMockito.then; + +/** + * Tests that {@link MockitoSpyBean @MockitoSpyBean} can be used to replace an + * existing bean with circular dependencies with {@link Autowired @Autowired} + * setter methods. + * + * @author Andy Wilkinson + * @author Sam Brannen + * @since 6.2 + * @see MockitoSpyBeanAndCircularDependenciesWithLazyResolutionProxyIntegrationTests + */ +@SpringJUnitConfig +@DisabledInAotMode("Circular dependencies cannot be resolved in AOT mode unless a @Lazy resolution proxy is used") +class MockitoSpyBeanAndCircularDependenciesWithAutowiredSettersIntegrationTests { + + @MockitoSpyBean + One one; + + @Autowired + Two two; + + + @Test + void beanWithCircularDependenciesCanBeSpied() { + two.callOne(); + then(one).should().doSomething(); + } + + + @Configuration + @Import({ One.class, Two.class }) + static class Config { + } + + static class One { + + @SuppressWarnings("unused") + private Two two; + + @Autowired + void setTwo(Two two) { + this.two = two; + } + + void doSomething() { + } + } + + static class Two { + + private One one; + + @Autowired + void setOne(One one) { + this.one = one; + } + + void callOne() { + this.one.doSomething(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanAndCircularDependenciesWithLazyResolutionProxyIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanAndCircularDependenciesWithLazyResolutionProxyIntegrationTests.java new file mode 100644 index 000000000000..3a6cb5097095 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanAndCircularDependenciesWithLazyResolutionProxyIntegrationTests.java @@ -0,0 +1,93 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Lazy; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.mockito.BDDMockito.then; + +/** + * Tests that {@link MockitoSpyBean @MockitoSpyBean} can be used to replace an + * existing bean with circular dependencies with {@link Autowired @Autowired} + * setter methods and a {@link Lazy @Lazy} resolution proxy. + * + *

    In contrast to {@link MockitoSpyBeanAndCircularDependenciesWithAutowiredSettersIntegrationTests}, + * this test class works in AOT mode. + * + * @author Sam Brannen + * @author Andy Wilkinson + * @since 6.2 + * @see MockitoSpyBeanAndCircularDependenciesWithAutowiredSettersIntegrationTests + */ +@SpringJUnitConfig +class MockitoSpyBeanAndCircularDependenciesWithLazyResolutionProxyIntegrationTests { + + @MockitoSpyBean + One one; + + @Autowired + Two two; + + + @Test + void beanWithCircularDependenciesCanBeSpied() { + two.callOne(); + then(one).should().doSomething(); + } + + + @Configuration + @Import({ One.class, Two.class }) + static class Config { + } + + static class One { + + @SuppressWarnings("unused") + private Two two; + + @Autowired + void setTwo(@Lazy Two two) { + this.two = two; + } + + void doSomething() { + } + } + + static class Two { + + private One one; + + @Autowired + void setOne(One one) { + this.one = one; + } + + void callOne() { + this.one.doSomething(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanAndContextHierarchyChildIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanAndContextHierarchyChildIntegrationTests.java new file mode 100644 index 000000000000..b5f02fa893f0 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanAndContextHierarchyChildIntegrationTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.ContextHierarchy; +import org.springframework.test.context.aot.DisabledInAotMode; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.ExampleServiceCaller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests which verify that {@link MockitoBean @MockitoBean} and + * {@link MockitoSpyBean @MockitoSpyBean} can be used within a + * {@link ContextHierarchy @ContextHierarchy}. + * + * @author Sam Brannen + * @author Phillip Webb + * @since 6.2 + * @see MockitoBeanAndContextHierarchyParentIntegrationTests + */ +@ContextHierarchy(@ContextConfiguration) +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") +public class MockitoSpyBeanAndContextHierarchyChildIntegrationTests extends + MockitoBeanAndContextHierarchyParentIntegrationTests { + + @MockitoSpyBean + ExampleServiceCaller serviceCaller; + + @Autowired + ApplicationContext context; + + + @Test + @Override + void test() { + assertThat(context).as("child ApplicationContext").isNotNull(); + assertThat(context.getParent()).as("parent ApplicationContext").isNotNull(); + assertThat(context.getParent().getParent()).as("grandparent ApplicationContext").isNull(); + + ApplicationContext parentContext = context.getParent(); + assertThat(parentContext.getBeanNamesForType(ExampleService.class)).hasSize(1); + assertThat(parentContext.getBeanNamesForType(ExampleServiceCaller.class)).isEmpty(); + + assertThat(context.getBeanNamesForType(ExampleService.class)).hasSize(1); + assertThat(context.getBeanNamesForType(ExampleServiceCaller.class)).hasSize(1); + + assertThat(service.greeting()).isEqualTo("mock"); + assertThat(serviceCaller.sayGreeting()).isEqualTo("I say mock"); + } + + + @Configuration(proxyBeanMethods = false) + static class ChildConfig { + + @Bean + ExampleServiceCaller serviceCaller(ExampleService service) { + return new ExampleServiceCaller(service); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanAndSpringAopProxyIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanAndSpringAopProxyIntegrationTests.java new file mode 100644 index 000000000000..80fcb6d08bed --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanAndSpringAopProxyIntegrationTests.java @@ -0,0 +1,191 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.aop.support.AopUtils; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.cache.interceptor.CacheResolver; +import org.springframework.cache.interceptor.SimpleCacheResolver; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.AopTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.mockito.MockitoAssertions.assertIsSpy; + +/** + * Tests for {@link MockitoSpyBean @MockitoSpyBean} used in combination with Spring AOP. + * + * @author Sam Brannen + * @author Phillip Webb + * @since 6.2 + * @see 5837 + * @see MockitoBeanAndSpringAopProxyIntegrationTests + */ +@ExtendWith(SpringExtension.class) +class MockitoSpyBeanAndSpringAopProxyIntegrationTests { + + @MockitoSpyBean + DateService dateService; + + + @BeforeEach + void resetCache() { + // We have to clear the "test" cache before each test. Otherwise, method + // invocations on the Spring AOP proxy will never make it to the Mockito spy. + dateService.clearCache(); + } + + /** + * Stubbing and verification for a Mockito spy that is wrapped in a Spring AOP + * proxy should always work when performed via the ultimate target of the Spring + * AOP proxy (i.e., the actual spy instance). + */ + // We need to run this test at least twice to ensure the Mockito spy can be reused + // across test method invocations without using @DirtestContext. + @RepeatedTest(2) + void stubAndVerifyOnUltimateTargetOfSpringAopProxy() { + assertThat(AopUtils.isAopProxy(dateService)).as("is Spring AOP proxy").isTrue(); + DateService spy = AopTestUtils.getUltimateTargetObject(dateService); + assertIsSpy(dateService, "ultimate target"); + + given(spy.getDate(false)).willReturn(1L); + Long date = dateService.getDate(false); + assertThat(date).isOne(); + + given(spy.getDate(false)).willReturn(2L); + date = dateService.getDate(false); + assertThat(date).isEqualTo(1L); // 1L instead of 2L, because the AOP proxy caches the original value. + + // Each of the following verifies times(1), because the AOP proxy caches the + // original value and does not delegate to the spy on subsequent invocations. + verify(spy, times(1)).getDate(false); + verify(spy, times(1)).getDate(eq(false)); + verify(spy, times(1)).getDate(anyBoolean()); + } + + /** + * Verification for a Mockito spy that is wrapped in a Spring AOP proxy should + * always work when performed via the Spring AOP proxy. However, stubbing + * does not currently work via the Spring AOP proxy. + * + *

    Consequently, this test method supplies the ultimate target of the Spring + * AOP proxy to stubbing calls, while supplying the Spring AOP proxy to verification + * calls. + */ + // We need to run this test at least twice to ensure the Mockito spy can be reused + // across test method invocations without using @DirtestContext. + @RepeatedTest(2) + void stubOnUltimateTargetAndVerifyOnSpringAopProxy() { + assertThat(AopUtils.isAopProxy(dateService)).as("is Spring AOP proxy").isTrue(); + assertIsSpy(dateService, "Spring AOP proxy"); + + DateService spy = AopTestUtils.getUltimateTargetObject(dateService); + given(spy.getDate(false)).willReturn(1L); + Long date = dateService.getDate(false); + assertThat(date).isOne(); + + given(spy.getDate(false)).willReturn(2L); + date = dateService.getDate(false); + assertThat(date).isEqualTo(1L); // 1L instead of 2L, because the AOP proxy caches the original value. + + // Each of the following verifies times(1), because the AOP proxy caches the + // original value and does not delegate to the spy on subsequent invocations. + verify(dateService, times(1)).getDate(false); + verify(dateService, times(1)).getDate(eq(false)); + verify(dateService, times(1)).getDate(anyBoolean()); + } + + /** + * Ideally, both stubbing and verification should work transparently when a Mockito + * spy is wrapped in a Spring AOP proxy. However, Mockito currently does not provide + * support for transparent stubbing of a proxied spy. For example, implementing a + * custom {@link org.mockito.plugins.MockResolver} will not result in successful + * stubbing for a proxied mock. + */ + @Disabled("Disabled until Mockito provides support for transparent stubbing of a proxied spy") + // We need to run this test at least twice to ensure the Mockito spy can be reused + // across test method invocations without using @DirtestContext. + @RepeatedTest(2) + void stubAndVerifyDirectlyOnSpringAopProxy() throws Exception { + assertThat(AopUtils.isCglibProxy(dateService)).as("is Spring AOP CGLIB proxy").isTrue(); + assertIsSpy(dateService); + + doReturn(1L).when(dateService).getDate(false); + Long date = dateService.getDate(false); + assertThat(date).isOne(); + + doReturn(2L).when(dateService).getDate(false); + date = dateService.getDate(false); + assertThat(date).isEqualTo(1L); // 1L instead of 2L, because the AOP proxy caches the original value. + + // Each of the following verifies times(1), because the AOP proxy caches the + // original value and does not delegate to the spy on subsequent invocations. + verify(dateService, times(1)).getDate(false); + verify(dateService, times(1)).getDate(eq(false)); + verify(dateService, times(1)).getDate(anyBoolean()); + } + + + @Configuration(proxyBeanMethods = false) + @EnableCaching(proxyTargetClass = true) + @Import(DateService.class) + static class Config { + + @Bean + CacheResolver cacheResolver(CacheManager cacheManager) { + return new SimpleCacheResolver(cacheManager); + } + + @Bean + ConcurrentMapCacheManager cacheManager() { + return new ConcurrentMapCacheManager("test"); + } + } + + static class DateService { + + @Cacheable("test") + Long getDate(boolean argument) { + return System.nanoTime(); + } + + @CacheEvict(cacheNames = "test", allEntries = true) + void clearCache() { + } + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanUsedDuringApplicationContextRefreshIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanUsedDuringApplicationContextRefreshIntegrationTests.java new file mode 100644 index 000000000000..1b2483beda02 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanUsedDuringApplicationContextRefreshIntegrationTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.mockito.MockitoAssertions.assertIsSpy; + +/** + * Integration tests for {@link MockitoSpyBean @MockitoSpyBean} used during + * {@code ApplicationContext} refresh. + * + * @author Sam Brannen + * @since 6.2.1 + * @see MockitoBeanUsedDuringApplicationContextRefreshIntegrationTests + */ +@SpringJUnitConfig +class MockitoSpyBeanUsedDuringApplicationContextRefreshIntegrationTests { + + static ContextRefreshedEvent contextRefreshedEvent; + + @MockitoSpyBean + ContextRefreshedEventProcessor eventProcessor; + + + @AfterAll + static void clearStaticField() { + contextRefreshedEvent = null; + } + + @Test + void test() { + assertIsSpy(eventProcessor); + + // Ensure that the spy was invoked during ApplicationContext refresh + // and has not been reset in the interim. + then(eventProcessor).should().process(same(contextRefreshedEvent)); + } + + + @Configuration + @Import(ContextRefreshedEventListener.class) + static class Config { + + @Bean + ContextRefreshedEventProcessor eventProcessor() { + // Cannot be a lambda expression, since Mockito cannot create a spy for a lambda. + return new ContextRefreshedEventProcessor() { + + @Override + public void process(ContextRefreshedEvent event) { + contextRefreshedEvent = event; + } + }; + } + } + + interface ContextRefreshedEventProcessor { + void process(ContextRefreshedEvent event); + } + + // MUST be annotated with @Component, due to EventListenerMethodProcessor.isSpringContainerClass(). + @Component + record ContextRefreshedEventListener(ContextRefreshedEventProcessor contextRefreshedEventProcessor) { + + @EventListener + void onApplicationEvent(ContextRefreshedEvent event) { + this.contextRefreshedEventProcessor.process(event); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithDirtiesContextBeforeMethodIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithDirtiesContextBeforeMethodIntegrationTests.java new file mode 100644 index 000000000000..7fdf06800748 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithDirtiesContextBeforeMethodIntegrationTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.RepeatedTest; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.MethodMode; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.example.ExampleServiceCaller; +import org.springframework.test.context.bean.override.example.SimpleExampleService; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.annotation.DirtiesContext.MethodMode.BEFORE_METHOD; + +/** + * Integration tests for using {@link MockitoSpyBean @MockitoSpyBean} with + * {@link DirtiesContext @DirtiesContext} and {@link MethodMode#BEFORE_METHOD}. + * + * @author Andy Wilkinson + * @author Sam Brannen + * @since 6.2 + * @see MockitoBeanWithDirtiesContextBeforeMethodIntegrationTests + */ +@SpringJUnitConfig +class MockitoSpyBeanWithDirtiesContextBeforeMethodIntegrationTests { + + @Autowired + ExampleServiceCaller caller; + + @MockitoSpyBean + SimpleExampleService service; + + @Autowired + ExampleService autowiredService; + + + @RepeatedTest(2) + @DirtiesContext(methodMode = BEFORE_METHOD) + void testSpying() { + assertThat(service).isSameAs(autowiredService); + + assertThat(caller.sayGreeting()).isEqualTo("I say simple"); + then(service).should().greeting(); + } + + + @Configuration(proxyBeanMethods = false) + @Import(SimpleExampleService.class) + static class Config { + + @Bean + ExampleServiceCaller serviceCaller(ExampleService service) { + return new ExampleServiceCaller(service); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithGenericsOnTestFieldForExistingGenericBeanIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithGenericsOnTestFieldForExistingGenericBeanIntegrationTests.java new file mode 100644 index 000000000000..b8386d6e2308 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithGenericsOnTestFieldForExistingGenericBeanIntegrationTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.example.ExampleGenericService; +import org.springframework.test.context.bean.override.example.ExampleGenericServiceCaller; +import org.springframework.test.context.bean.override.example.IntegerExampleGenericService; +import org.springframework.test.context.bean.override.example.StringExampleGenericService; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; + +/** + * Tests that {@link MockitoSpyBean @MockitoSpyBean} on a field with generics can + * be used to replace an existing bean with matching generics. + * + * @author Phillip Webb + * @author Sam Brannen + * @since 6.2 + * @see MockitoBeanWithGenericsOnTestFieldForNewBeanIntegrationTests + * @see MockitoSpyBeanWithGenericsOnTestFieldForExistingGenericBeanProducedByFactoryBeanIntegrationTests + */ +@SpringJUnitConfig +class MockitoSpyBeanWithGenericsOnTestFieldForExistingGenericBeanIntegrationTests { + + @MockitoSpyBean + ExampleGenericService service; + + @Autowired + ExampleGenericServiceCaller caller; + + + @Test + void testSpying() { + assertThat(caller.sayGreeting()).isEqualTo("I say Enigma 123"); + then(service).should().greeting(); + } + + + @Configuration(proxyBeanMethods = false) + @Import({ ExampleGenericServiceCaller.class, IntegerExampleGenericService.class }) + static class Config { + + @Bean + ExampleGenericService simpleExampleStringGenericService() { + // In order to trigger the issue, we need a method signature that returns the + // generic type instead of the actual implementation class. + return new StringExampleGenericService("Enigma"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithGenericsOnTestFieldForExistingGenericBeanProducedByFactoryBeanIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithGenericsOnTestFieldForExistingGenericBeanProducedByFactoryBeanIntegrationTests.java new file mode 100644 index 000000000000..470479e4e169 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithGenericsOnTestFieldForExistingGenericBeanProducedByFactoryBeanIntegrationTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.ResolvableType; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.test.context.bean.override.example.ExampleGenericService; +import org.springframework.test.context.bean.override.example.StringExampleGenericService; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mockingDetails; +import static org.springframework.test.mockito.MockitoAssertions.assertIsSpy; + +/** + * Tests that {@link MockitoSpyBean @MockitoSpyBean} on a field with generics can + * be used to replace an existing bean with matching generics that's produced by a + * {@link FactoryBean} that's programmatically registered via an + * {@link ImportBeanDefinitionRegistrar}. + * + * @author Andy Wilkinson + * @author Sam Brannen + * @since 6.2 + * @see MockitoSpyBeanWithGenericsOnTestFieldForExistingGenericBeanIntegrationTests + */ +@SpringJUnitConfig +class MockitoSpyBeanWithGenericsOnTestFieldForExistingGenericBeanProducedByFactoryBeanIntegrationTests { + + @MockitoSpyBean("exampleService") + ExampleGenericService exampleService; + + + @Test + void testSpying() { + assertIsSpy(exampleService); + + Object spiedInstance = mockingDetails(exampleService).getMockCreationSettings().getSpiedInstance(); + assertThat(spiedInstance).isInstanceOf(StringExampleGenericService.class); + } + + + @Configuration(proxyBeanMethods = false) + @Import(FactoryBeanRegistrar.class) + static class Config { + } + + static class FactoryBeanRegistrar implements ImportBeanDefinitionRegistrar { + + @Override + public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, + BeanDefinitionRegistry registry) { + + RootBeanDefinition definition = new RootBeanDefinition(ExampleGenericServiceFactoryBean.class); + ResolvableType targetType = ResolvableType.forClassWithGenerics( + ExampleGenericServiceFactoryBean.class, null, ExampleGenericService.class); + definition.setTargetType(targetType); + registry.registerBeanDefinition("exampleService", definition); + } + } + + static class ExampleGenericServiceFactoryBean> implements FactoryBean { + + @Override + @SuppressWarnings("unchecked") + public U getObject() throws Exception { + return (U) new StringExampleGenericService("Enigma"); + } + + @Override + @SuppressWarnings("rawtypes") + public Class getObjectType() { + return ExampleGenericService.class; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests.java new file mode 100644 index 000000000000..b9cabfaaea8f --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.example.ExampleGenericServiceCaller; +import org.springframework.test.context.bean.override.example.IntegerExampleGenericService; +import org.springframework.test.context.bean.override.example.StringExampleGenericService; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.mockito.MockitoAssertions.assertIsSpy; +import static org.springframework.test.mockito.MockitoAssertions.assertMockName; + +/** + * Tests that {@link MockitoSpyBean @MockitoSpyBean} can be used to spy on a bean + * when there are multiple candidates and an explicit bean name is supplied to + * select one of the candidates. + * + * @author Sam Brannen + * @author Phillip Webb + * @since 6.2 + * @see MockitoSpyBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests + * @see MockitoSpyBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests + */ +@SpringJUnitConfig +class MockitoSpyBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests { + + @MockitoSpyBean("stringService") + StringExampleGenericService spy; + + @Autowired + ExampleGenericServiceCaller caller; + + + @Test + void test() { + assertIsSpy(spy); + assertMockName(spy, "stringService"); + + assertThat(caller.sayGreeting()).isEqualTo("I say two 123"); + then(spy).should().greeting(); + } + + + @Configuration(proxyBeanMethods = false) + @Import({ ExampleGenericServiceCaller.class, IntegerExampleGenericService.class }) + static class Config { + + @Bean + StringExampleGenericService one() { + return new StringExampleGenericService("one"); + } + + @Bean + // "stringService" matches the constructor argument name in ExampleGenericServiceCaller + StringExampleGenericService stringService() { + return new StringExampleGenericService("two"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests.java new file mode 100644 index 000000000000..fe864dd2d799 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.example.ExampleGenericServiceCaller; +import org.springframework.test.context.bean.override.example.IntegerExampleGenericService; +import org.springframework.test.context.bean.override.example.StringExampleGenericService; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.mockito.MockitoAssertions.assertIsSpy; +import static org.springframework.test.mockito.MockitoAssertions.assertMockName; + +/** + * Tests that {@link MockitoSpyBean @MockitoSpyBean} can be used to spy on a bean + * when there are multiple candidates and a {@link Qualifier @Qualifier} is supplied + * to select one of the candidates. + * + * @author Sam Brannen + * @author Phillip Webb + * @since 6.2 + * @see MockitoSpyBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests + * @see MockitoSpyBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests + */ +@SpringJUnitConfig +class MockitoSpyBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests { + + @Qualifier("stringService") + @MockitoSpyBean + StringExampleGenericService spy; + + @Autowired + ExampleGenericServiceCaller caller; + + + @Test + void test() { + assertIsSpy(spy); + assertMockName(spy, "stringService"); + + assertThat(caller.sayGreeting()).isEqualTo("I say two 123"); + then(spy).should().greeting(); + } + + + @Configuration(proxyBeanMethods = false) + @Import({ ExampleGenericServiceCaller.class, IntegerExampleGenericService.class }) + static class Config { + + @Bean + StringExampleGenericService one() { + return new StringExampleGenericService("one"); + } + + @Bean + // "stringService" matches the constructor argument name in ExampleGenericServiceCaller + StringExampleGenericService stringService() { + return new StringExampleGenericService("two"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests.java new file mode 100644 index 000000000000..4084c5f192f7 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoSpyBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.bean.override.example.ExampleGenericServiceCaller; +import org.springframework.test.context.bean.override.example.IntegerExampleGenericService; +import org.springframework.test.context.bean.override.example.StringExampleGenericService; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.mockito.MockitoAssertions.assertIsSpy; +import static org.springframework.test.mockito.MockitoAssertions.assertMockName; + +/** + * Tests that {@link MockitoSpyBean @MockitoSpyBean} can be used to spy on a bean + * when there are multiple candidates and one is {@link Primary @Primary}. + * + * @author Phillip Webb + * @author Sam Brannen + * @since 6.2 + * @see MockitoSpyBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests + * @see MockitoSpyBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests + */ +@ExtendWith(SpringExtension.class) +class MockitoSpyBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests { + + @MockitoSpyBean + StringExampleGenericService spy; + + @Autowired + ExampleGenericServiceCaller caller; + + + @Test + void testSpying() { + assertIsSpy(spy); + assertMockName(spy, "two"); + + assertThat(caller.sayGreeting()).isEqualTo("I say two 123"); + then(spy).should().greeting(); + } + + + @Configuration(proxyBeanMethods = false) + @Import({ ExampleGenericServiceCaller.class, IntegerExampleGenericService.class }) + static class Config { + + @Bean + StringExampleGenericService one() { + return new StringExampleGenericService("one"); + } + + @Bean + @Primary + StringExampleGenericService two() { + return new StringExampleGenericService("two"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/SpringExtensionAndMockitoExtensionIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/SpringExtensionAndMockitoExtensionIntegrationTests.java new file mode 100644 index 000000000000..1fa8133ff9ef --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/SpringExtensionAndMockitoExtensionIntegrationTests.java @@ -0,0 +1,93 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.integration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +/** + * Integration tests that verify support for {@link MockitoBean @MockitoBean} + * and {@link MockitoSpyBean @MockitoSpyBean} when the {@link MockitoExtension} + * is registered alongside the {@link SpringExtension}. + * + *

    This test class currently verifies explicit support for {@link Captor @Captor}, + * but we may extend the scope of this test class in the future. + * + * @author Sam Brannen + * @since 6.2 + */ +@ExtendWith(MockitoExtension.class) +@ExtendWith(SpringExtension.class) +public class SpringExtensionAndMockitoExtensionIntegrationTests { + + @MockitoSpyBean + RegistrationService registrationService; + + @MockitoBean + UserService userService; + + @Captor + ArgumentCaptor userCaptor; + + + @Test + void test() { + registrationService.registerUser("Duke"); + verify(registrationService).registerUser("Duke"); + verify(userService).validateUser(userCaptor.capture()); + assertThat(userCaptor.getValue().name).isEqualTo("Duke"); + } + + @Configuration + static class Config { + + @Bean + RegistrationService registrationService(UserService userService) { + return new RegistrationService(userService); + } + } + + interface UserService { + + void validateUser(User user); + } + + record RegistrationService(UserService userService) { + + void registerUser(String name) { + User user = new User(name); + this.userService.validateUser(user); + // Register user... + } + } + + record User(String name) { + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockTestInterface01.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockTestInterface01.java new file mode 100644 index 000000000000..394a04b9b116 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockTestInterface01.java @@ -0,0 +1,23 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@MockitoBean(types = Service01.class) +interface MockTestInterface01 { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockTestInterface08.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockTestInterface08.java new file mode 100644 index 000000000000..4aeeced98230 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockTestInterface08.java @@ -0,0 +1,23 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@MockitoBean(types = Service08.class) +interface MockTestInterface08 { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockTestInterface11.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockTestInterface11.java new file mode 100644 index 000000000000..1f8e1511b521 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockTestInterface11.java @@ -0,0 +1,23 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@MockitoBean(types = Service11.class) +interface MockTestInterface11 { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByNameIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByNameIntegrationTests.java new file mode 100644 index 000000000000..272f767e4a98 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByNameIntegrationTests.java @@ -0,0 +1,110 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoBeans; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; +import static org.springframework.test.mockito.MockitoAssertions.assertIsNotMock; + +/** + * Integration tests for {@link MockitoBeans @MockitoBeans} and + * {@link MockitoBean @MockitoBean} declared "by name" at the class level as a + * repeatable annotation. + * + * @author Sam Brannen + * @since 6.2.2 + * @see gh-33925 + * @see MockitoBeansByTypeIntegrationTests + */ +@SpringJUnitConfig +@MockitoBean(name = "s1", types = ExampleService.class) +@MockitoBean(name = "s2", types = ExampleService.class) +class MockitoBeansByNameIntegrationTests { + + @Autowired + ExampleService s1; + + @Autowired + ExampleService s2; + + @MockitoBean(name = "s3") + ExampleService service3; + + @Autowired + @Qualifier("s4") + ExampleService service4; + + + @BeforeEach + void configureMocks() { + given(s1.greeting()).willReturn("mock 1"); + given(s2.greeting()).willReturn("mock 2"); + given(service3.greeting()).willReturn("mock 3"); + } + + @Test + void checkMocksAndStandardBean() { + assertIsMock(s1, "s1"); + assertIsMock(s2, "s2"); + assertIsMock(service3, "service3"); + assertIsNotMock(service4, "service4"); + + assertThat(s1.greeting()).isEqualTo("mock 1"); + assertThat(s2.greeting()).isEqualTo("mock 2"); + assertThat(service3.greeting()).isEqualTo("mock 3"); + assertThat(service4.greeting()).isEqualTo("prod 4"); + } + + + @Configuration + static class Config { + + @Bean + ExampleService s1() { + return () -> "prod 1"; + } + + @Bean + ExampleService s2() { + return () -> "prod 2"; + } + + @Bean + ExampleService s3() { + return () -> "prod 3"; + } + + @Bean + ExampleService s4() { + return () -> "prod 4"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByTypeIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByTypeIntegrationTests.java new file mode 100644 index 000000000000..ccae3865b345 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByTypeIntegrationTests.java @@ -0,0 +1,170 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoBeans; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; + +/** + * Integration tests for {@link MockitoBeans @MockitoBeans} and + * {@link MockitoBean @MockitoBean} declared "by type" at the class level, as a + * repeatable annotation, and via a custom composed annotation. + * + * @author Sam Brannen + * @since 6.2.2 + * @see gh-33925 + * @see MockitoBeansByNameIntegrationTests + */ +@SpringJUnitConfig +@MockitoBean(types = {Service04.class, Service05.class}) +@SharedMocks // Intentionally declared between local @MockitoBean declarations +@MockitoBean(types = Service06.class) +class MockitoBeansByTypeIntegrationTests implements MockTestInterface01 { + + @Autowired + Service01 service01; + + @Autowired + Service02 service02; + + @Autowired + Service03 service03; + + @Autowired + Service04 service04; + + @Autowired + Service05 service05; + + @Autowired + Service06 service06; + + @MockitoBean + Service07 service07; + + + @BeforeEach + void configureMocks() { + given(service01.greeting()).willReturn("mock 01"); + given(service02.greeting()).willReturn("mock 02"); + given(service03.greeting()).willReturn("mock 03"); + given(service04.greeting()).willReturn("mock 04"); + given(service05.greeting()).willReturn("mock 05"); + given(service06.greeting()).willReturn("mock 06"); + given(service07.greeting()).willReturn("mock 07"); + } + + @Test + void checkMocks() { + assertIsMock(service01, "service01"); + assertIsMock(service02, "service02"); + assertIsMock(service03, "service03"); + assertIsMock(service04, "service04"); + assertIsMock(service05, "service05"); + assertIsMock(service06, "service06"); + assertIsMock(service07, "service07"); + + assertThat(service01.greeting()).isEqualTo("mock 01"); + assertThat(service02.greeting()).isEqualTo("mock 02"); + assertThat(service03.greeting()).isEqualTo("mock 03"); + assertThat(service04.greeting()).isEqualTo("mock 04"); + assertThat(service05.greeting()).isEqualTo("mock 05"); + assertThat(service06.greeting()).isEqualTo("mock 06"); + assertThat(service07.greeting()).isEqualTo("mock 07"); + } + + + @MockitoBean(types = Service09.class) + class BaseTestCase implements MockTestInterface08 { + + @Autowired + Service08 service08; + + @Autowired + Service09 service09; + + @MockitoBean + Service10 service10; + } + + @Nested + @MockitoBean(types = Service12.class) + class NestedTests extends BaseTestCase implements MockTestInterface11 { + + @Autowired + Service11 service11; + + @Autowired + Service12 service12; + + @MockitoBean + Service13 service13; + + + @BeforeEach + void configureMocks() { + given(service08.greeting()).willReturn("mock 08"); + given(service09.greeting()).willReturn("mock 09"); + given(service10.greeting()).willReturn("mock 10"); + given(service11.greeting()).willReturn("mock 11"); + given(service12.greeting()).willReturn("mock 12"); + given(service13.greeting()).willReturn("mock 13"); + } + + @Test + void checkMocks() { + assertIsMock(service01, "service01"); + assertIsMock(service02, "service02"); + assertIsMock(service03, "service03"); + assertIsMock(service04, "service04"); + assertIsMock(service05, "service05"); + assertIsMock(service06, "service06"); + assertIsMock(service07, "service07"); + assertIsMock(service08, "service08"); + assertIsMock(service09, "service09"); + assertIsMock(service10, "service10"); + assertIsMock(service11, "service11"); + assertIsMock(service12, "service12"); + assertIsMock(service13, "service13"); + + assertThat(service01.greeting()).isEqualTo("mock 01"); + assertThat(service02.greeting()).isEqualTo("mock 02"); + assertThat(service03.greeting()).isEqualTo("mock 03"); + assertThat(service04.greeting()).isEqualTo("mock 04"); + assertThat(service05.greeting()).isEqualTo("mock 05"); + assertThat(service06.greeting()).isEqualTo("mock 06"); + assertThat(service07.greeting()).isEqualTo("mock 07"); + assertThat(service08.greeting()).isEqualTo("mock 08"); + assertThat(service09.greeting()).isEqualTo("mock 09"); + assertThat(service10.greeting()).isEqualTo("mock 10"); + assertThat(service11.greeting()).isEqualTo("mock 11"); + assertThat(service12.greeting()).isEqualTo("mock 12"); + assertThat(service13.greeting()).isEqualTo("mock 13"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansTests.java new file mode 100644 index 000000000000..28b4f87e8c72 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansTests.java @@ -0,0 +1,68 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.ResolvableType; +import org.springframework.test.context.bean.override.BeanOverrideHandler; +import org.springframework.test.context.bean.override.BeanOverrideTestUtils; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoBeans; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link MockitoBeans @MockitoBeans}: {@link MockitoBean @MockitoBean} + * declared at the class level, as a repeatable annotation, and via a custom composed + * annotation. + * + * @author Sam Brannen + * @since 6.2.2 + * @see gh-33925 + */ +class MockitoBeansTests { + + @Test + void registrationOrderForTopLevelClass() { + Stream> mockedServices = getRegisteredMockTypes(MockitoBeansByTypeIntegrationTests.class); + assertThat(mockedServices).containsExactly( + Service01.class, Service02.class, Service03.class, Service04.class, + Service05.class, Service06.class, Service07.class); + } + + @Test + void registrationOrderForNestedClass() { + Stream> mockedServices = getRegisteredMockTypes(MockitoBeansByTypeIntegrationTests.NestedTests.class); + assertThat(mockedServices).containsExactly( + Service01.class, Service02.class, Service03.class, Service04.class, + Service05.class, Service06.class, Service07.class, Service08.class, + Service09.class, Service10.class, Service11.class, Service12.class, + Service13.class); + } + + + private static Stream> getRegisteredMockTypes(Class testClass) { + return BeanOverrideTestUtils.findAllHandlers(testClass) + .stream() + .map(BeanOverrideHandler::getBeanType) + .map(ResolvableType::getRawClass); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoSpyBeansByNameIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoSpyBeansByNameIntegrationTests.java new file mode 100644 index 000000000000..b35c03aebd71 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoSpyBeansByNameIntegrationTests.java @@ -0,0 +1,127 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBeans; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.mockito.MockitoAssertions.assertIsNotMock; +import static org.springframework.test.mockito.MockitoAssertions.assertIsNotSpy; +import static org.springframework.test.mockito.MockitoAssertions.assertIsSpy; + +/** + * Integration tests for {@link MockitoSpyBeans @MockitoSpyBeans} and + * {@link MockitoSpyBean @MockitoSpyBean} declared "by name" at the class level + * as a repeatable annotation. + * + * @author Sam Brannen + * @since 6.2.3 + * @see gh-34408 + * @see MockitoSpyBeansByTypeIntegrationTests + */ +@SpringJUnitConfig +@MockitoSpyBean(name = "s1", types = ExampleService.class) +@MockitoSpyBean(name = "s2", types = ExampleService.class) +class MockitoSpyBeansByNameIntegrationTests { + + @Autowired + ExampleService s1; + + @Autowired + ExampleService s2; + + @MockitoSpyBean(name = "s3") + ExampleService service3; + + @Autowired + @Qualifier("s4") + ExampleService service4; + + + @BeforeEach + void configureSpies() { + given(s1.greeting()).willReturn("spy 1"); + given(s2.greeting()).willReturn("spy 2"); + given(service3.greeting()).willReturn("spy 3"); + } + + @Test + void checkSpiesAndStandardBean() { + assertIsSpy(s1, "s1"); + assertIsSpy(s2, "s2"); + assertIsSpy(service3, "service3"); + assertIsNotMock(service4, "service4"); + assertIsNotSpy(service4, "service4"); + + assertThat(s1.greeting()).isEqualTo("spy 1"); + assertThat(s2.greeting()).isEqualTo("spy 2"); + assertThat(service3.greeting()).isEqualTo("spy 3"); + assertThat(service4.greeting()).isEqualTo("prod 4"); + } + + + @Configuration + static class Config { + + @Bean + ExampleService s1() { + return new ExampleService() { + @Override + public String greeting() { + return "prod 1"; + } + }; + } + + @Bean + ExampleService s2() { + return new ExampleService() { + @Override + public String greeting() { + return "prod 2"; + } + }; + } + + @Bean + ExampleService s3() { + return new ExampleService() { + @Override + public String greeting() { + return "prod 3"; + } + }; + } + + @Bean + ExampleService s4() { + return () -> "prod 4"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoSpyBeansByTypeIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoSpyBeansByTypeIntegrationTests.java new file mode 100644 index 000000000000..5454976fa2e2 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoSpyBeansByTypeIntegrationTests.java @@ -0,0 +1,307 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBeans; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.mockito.MockitoAssertions.assertIsSpy; + +/** + * Integration tests for {@link MockitoSpyBeans @MockitoSpyBeans} and + * {@link MockitoSpyBean @MockitoSpyBean} declared "by type" at the class level, + * as a repeatable annotation, and via a custom composed annotation. + * + * @author Sam Brannen + * @since 6.2.3 + * @see gh-34408 + * @see MockitoSpyBeansByNameIntegrationTests + */ +@SpringJUnitConfig +@MockitoSpyBean(types = {Service04.class, Service05.class}) +@SharedSpies // Intentionally declared between local @MockitoSpyBean declarations +@MockitoSpyBean(types = Service06.class) +class MockitoSpyBeansByTypeIntegrationTests implements SpyTestInterface01 { + + @Autowired + Service01 service01; + + @Autowired + Service02 service02; + + @Autowired + Service03 service03; + + @Autowired + Service04 service04; + + @Autowired + Service05 service05; + + @Autowired + Service06 service06; + + @MockitoSpyBean + Service07 service07; + + + @BeforeEach + void configureSpies() { + given(service01.greeting()).willReturn("spy 01"); + given(service02.greeting()).willReturn("spy 02"); + given(service03.greeting()).willReturn("spy 03"); + given(service04.greeting()).willReturn("spy 04"); + given(service05.greeting()).willReturn("spy 05"); + given(service06.greeting()).willReturn("spy 06"); + given(service07.greeting()).willReturn("spy 07"); + } + + @Test + void checkSpies() { + assertIsSpy(service01, "service01"); + assertIsSpy(service02, "service02"); + assertIsSpy(service03, "service03"); + assertIsSpy(service04, "service04"); + assertIsSpy(service05, "service05"); + assertIsSpy(service06, "service06"); + assertIsSpy(service07, "service07"); + + assertThat(service01.greeting()).isEqualTo("spy 01"); + assertThat(service02.greeting()).isEqualTo("spy 02"); + assertThat(service03.greeting()).isEqualTo("spy 03"); + assertThat(service04.greeting()).isEqualTo("spy 04"); + assertThat(service05.greeting()).isEqualTo("spy 05"); + assertThat(service06.greeting()).isEqualTo("spy 06"); + assertThat(service07.greeting()).isEqualTo("spy 07"); + } + + + @MockitoSpyBean(types = Service09.class) + class BaseTestCase implements SpyTestInterface08 { + + @Autowired + Service08 service08; + + @Autowired + Service09 service09; + + @MockitoSpyBean + Service10 service10; + } + + @Nested + @MockitoSpyBean(types = Service12.class) + class NestedTests extends BaseTestCase implements SpyTestInterface11 { + + @Autowired + Service11 service11; + + @Autowired + Service12 service12; + + @MockitoSpyBean + Service13 service13; + + + @BeforeEach + void configureSpies() { + given(service08.greeting()).willReturn("spy 08"); + given(service09.greeting()).willReturn("spy 09"); + given(service10.greeting()).willReturn("spy 10"); + given(service11.greeting()).willReturn("spy 11"); + given(service12.greeting()).willReturn("spy 12"); + given(service13.greeting()).willReturn("spy 13"); + } + + @Test + void checkSpies() { + assertIsSpy(service01, "service01"); + assertIsSpy(service02, "service02"); + assertIsSpy(service03, "service03"); + assertIsSpy(service04, "service04"); + assertIsSpy(service05, "service05"); + assertIsSpy(service06, "service06"); + assertIsSpy(service07, "service07"); + assertIsSpy(service08, "service08"); + assertIsSpy(service09, "service09"); + assertIsSpy(service10, "service10"); + assertIsSpy(service11, "service11"); + assertIsSpy(service12, "service12"); + assertIsSpy(service13, "service13"); + + assertThat(service01.greeting()).isEqualTo("spy 01"); + assertThat(service02.greeting()).isEqualTo("spy 02"); + assertThat(service03.greeting()).isEqualTo("spy 03"); + assertThat(service04.greeting()).isEqualTo("spy 04"); + assertThat(service05.greeting()).isEqualTo("spy 05"); + assertThat(service06.greeting()).isEqualTo("spy 06"); + assertThat(service07.greeting()).isEqualTo("spy 07"); + assertThat(service08.greeting()).isEqualTo("spy 08"); + assertThat(service09.greeting()).isEqualTo("spy 09"); + assertThat(service10.greeting()).isEqualTo("spy 10"); + assertThat(service11.greeting()).isEqualTo("spy 11"); + assertThat(service12.greeting()).isEqualTo("spy 12"); + assertThat(service13.greeting()).isEqualTo("spy 13"); + } + } + + + @Configuration + static class Config { + + @Bean + Service01 service01() { + return new Service01() { + @Override + public String greeting() { + return "prod 1"; + } + }; + } + + @Bean + Service02 service02() { + return new Service02() { + @Override + public String greeting() { + return "prod 2"; + } + }; + } + + @Bean + Service03 service03() { + return new Service03() { + @Override + public String greeting() { + return "prod 3"; + } + }; + } + + @Bean + Service04 service04() { + return new Service04() { + @Override + public String greeting() { + return "prod 4"; + } + }; + } + + @Bean + Service05 service05() { + return new Service05() { + @Override + public String greeting() { + return "prod 5"; + } + }; + } + + @Bean + Service06 service06() { + return new Service06() { + @Override + public String greeting() { + return "prod 6"; + } + }; + } + + @Bean + Service07 service07() { + return new Service07() { + @Override + public String greeting() { + return "prod 7"; + } + }; + } + + @Bean + Service08 service08() { + return new Service08() { + @Override + public String greeting() { + return "prod 8"; + } + }; + } + + @Bean + Service09 service09() { + return new Service09() { + @Override + public String greeting() { + return "prod 9"; + } + }; + } + + @Bean + Service10 service10() { + return new Service10() { + @Override + public String greeting() { + return "prod 10"; + } + }; + } + + @Bean + Service11 service11() { + return new Service11() { + @Override + public String greeting() { + return "prod 11"; + } + }; + } + + @Bean + Service12 service12() { + return new Service12() { + @Override + public String greeting() { + return "prod 12"; + } + }; + } + + @Bean + Service13 service13() { + return new Service13() { + @Override + public String greeting() { + return "prod 13"; + } + }; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoSpyBeansTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoSpyBeansTests.java new file mode 100644 index 000000000000..af0985e456e7 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoSpyBeansTests.java @@ -0,0 +1,68 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.ResolvableType; +import org.springframework.test.context.bean.override.BeanOverrideHandler; +import org.springframework.test.context.bean.override.BeanOverrideTestUtils; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBeans; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link MockitoSpyBeans @MockitoSpyBeans}: {@link MockitoSpyBean @MockitoSpyBean} + * declared at the class level, as a repeatable annotation, and via a custom composed + * annotation. + * + * @author Sam Brannen + * @since 6.2.3 + * @see gh-34408 + */ +class MockitoSpyBeansTests { + + @Test + void registrationOrderForTopLevelClass() { + Stream> mockedServices = getRegisteredMockTypes(MockitoSpyBeansByTypeIntegrationTests.class); + assertThat(mockedServices).containsExactly( + Service01.class, Service02.class, Service03.class, Service04.class, + Service05.class, Service06.class, Service07.class); + } + + @Test + void registrationOrderForNestedClass() { + Stream> mockedServices = getRegisteredMockTypes(MockitoSpyBeansByTypeIntegrationTests.NestedTests.class); + assertThat(mockedServices).containsExactly( + Service01.class, Service02.class, Service03.class, Service04.class, + Service05.class, Service06.class, Service07.class, Service08.class, + Service09.class, Service10.class, Service11.class, Service12.class, + Service13.class); + } + + + private static Stream> getRegisteredMockTypes(Class testClass) { + return BeanOverrideTestUtils.findAllHandlers(testClass) + .stream() + .map(BeanOverrideHandler::getBeanType) + .map(ResolvableType::getRawClass); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service.java new file mode 100644 index 000000000000..e0092704f82b --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service.java @@ -0,0 +1,21 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service { + String greeting(); +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service01.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service01.java new file mode 100644 index 000000000000..e99bb762659a --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service01.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service01 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service02.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service02.java new file mode 100644 index 000000000000..78de4a2efb0e --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service02.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service02 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service03.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service03.java new file mode 100644 index 000000000000..564a48ceac1d --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service03.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service03 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service04.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service04.java new file mode 100644 index 000000000000..25c70404d1fc --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service04.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service04 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service05.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service05.java new file mode 100644 index 000000000000..d8da96f5466b --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service05.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service05 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service06.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service06.java new file mode 100644 index 000000000000..00adf7c28e5a --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service06.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service06 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service07.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service07.java new file mode 100644 index 000000000000..d3715f93748d --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service07.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service07 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service08.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service08.java new file mode 100644 index 000000000000..af5f07ced931 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service08.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service08 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service09.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service09.java new file mode 100644 index 000000000000..363f591882e7 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service09.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service09 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service10.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service10.java new file mode 100644 index 000000000000..1fcaaff6eb7c --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service10.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service10 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service11.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service11.java new file mode 100644 index 000000000000..1c8b7cb33e9c --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service11.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service11 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service12.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service12.java new file mode 100644 index 000000000000..386b41294c93 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service12.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service12 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service13.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service13.java new file mode 100644 index 000000000000..5ac3a2828e47 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/Service13.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +interface Service13 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SharedMocks.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SharedMocks.java new file mode 100644 index 000000000000..39c9056613df --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SharedMocks.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@MockitoBean(types = Service02.class) +@MockitoBean(types = Service03.class) +@interface SharedMocks { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SharedSpies.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SharedSpies.java new file mode 100644 index 000000000000..f3cb10d7ba87 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SharedSpies.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@MockitoSpyBean(types = Service02.class) +@MockitoSpyBean(types = Service03.class) +@interface SharedSpies { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SpyTestInterface01.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SpyTestInterface01.java new file mode 100644 index 000000000000..9f8c24193964 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SpyTestInterface01.java @@ -0,0 +1,23 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +@MockitoSpyBean(types = Service01.class) +interface SpyTestInterface01 { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SpyTestInterface08.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SpyTestInterface08.java new file mode 100644 index 000000000000..37e74e64f05b --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SpyTestInterface08.java @@ -0,0 +1,23 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +@MockitoSpyBean(types = Service08.class) +interface SpyTestInterface08 { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SpyTestInterface11.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SpyTestInterface11.java new file mode 100644 index 000000000000..ed5bf93472b1 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/SpyTestInterface11.java @@ -0,0 +1,23 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.bean.override.mockito.typelevel; + +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +@MockitoSpyBean(types = Service11.class) +interface SpyTestInterface11 { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/configuration/interfaces/ContextHierarchyInterfaceTests.java b/spring-test/src/test/java/org/springframework/test/context/configuration/interfaces/ContextHierarchyInterfaceTests.java index 3f9122a2a3ad..6c23cdb3ab6f 100644 --- a/spring-test/src/test/java/org/springframework/test/context/configuration/interfaces/ContextHierarchyInterfaceTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/configuration/interfaces/ContextHierarchyInterfaceTests.java @@ -31,7 +31,7 @@ * @since 4.3 */ @ExtendWith(SpringExtension.class) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class ContextHierarchyInterfaceTests implements ContextHierarchyTestInterface { @Autowired diff --git a/spring-test/src/test/java/org/springframework/test/context/env/ExplicitPropertiesFileInClasspathTestPropertySourceTests.java b/spring-test/src/test/java/org/springframework/test/context/env/ExplicitPropertiesFileInClasspathTestPropertySourceTests.java index d278c2a18834..4b7d74ea472a 100644 --- a/spring-test/src/test/java/org/springframework/test/context/env/ExplicitPropertiesFileInClasspathTestPropertySourceTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/env/ExplicitPropertiesFileInClasspathTestPropertySourceTests.java @@ -27,7 +27,6 @@ * @since 4.1 */ @TestPropertySource("explicit.properties") -// Since ExplicitPropertiesFileTestPropertySourceTests is disabled in AOT mode, this class must be also. -@DisabledInAotMode +@DisabledInAotMode("Because ExplicitPropertiesFileTestPropertySourceTests is disabled in AOT mode") public class ExplicitPropertiesFileInClasspathTestPropertySourceTests extends AbstractExplicitPropertiesFileTests { } diff --git a/spring-test/src/test/java/org/springframework/test/context/env/ExplicitPropertiesFileTestPropertySourceTests.java b/spring-test/src/test/java/org/springframework/test/context/env/ExplicitPropertiesFileTestPropertySourceTests.java index 51fb4cb3c5c3..ec969da6510f 100644 --- a/spring-test/src/test/java/org/springframework/test/context/env/ExplicitPropertiesFileTestPropertySourceTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/env/ExplicitPropertiesFileTestPropertySourceTests.java @@ -35,9 +35,7 @@ * @since 5.2 */ @DisplayName("Explicit properties file in @TestPropertySource") -// Since Spring test's AOT processing support does not invoke test lifecycle methods such -// as @BeforeAll/@AfterAll, this test class simply is not supported for AOT processing. -@DisabledInAotMode +@DisabledInAotMode("Spring test's AOT processing support does not invoke lifecycle methods such as @BeforeAll/@AfterAll") class ExplicitPropertiesFileTestPropertySourceTests { static final String CURRENT_TEST_PACKAGE = "current.test.package"; diff --git a/spring-test/src/test/java/org/springframework/test/context/env/InheritedRelativePathPropertiesFileTestPropertySourceTests.java b/spring-test/src/test/java/org/springframework/test/context/env/InheritedRelativePathPropertiesFileTestPropertySourceTests.java index 8456f6c373c1..68e4d6837b0a 100644 --- a/spring-test/src/test/java/org/springframework/test/context/env/InheritedRelativePathPropertiesFileTestPropertySourceTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/env/InheritedRelativePathPropertiesFileTestPropertySourceTests.java @@ -27,8 +27,7 @@ * @author Sam Brannen * @since 4.1 */ -// Since ExplicitPropertiesFileTestPropertySourceTests is disabled in AOT mode, this class must be also. -@DisabledInAotMode +@DisabledInAotMode("Because ExplicitPropertiesFileTestPropertySourceTests is disabled in AOT mode") class InheritedRelativePathPropertiesFileTestPropertySourceTests extends ExplicitPropertiesFileInClasspathTestPropertySourceTests { diff --git a/spring-test/src/test/java/org/springframework/test/context/env/MergedPropertiesFilesOverriddenByInlinedPropertiesTestPropertySourceTests.java b/spring-test/src/test/java/org/springframework/test/context/env/MergedPropertiesFilesOverriddenByInlinedPropertiesTestPropertySourceTests.java index a9a885c0a0fd..ea3825201931 100644 --- a/spring-test/src/test/java/org/springframework/test/context/env/MergedPropertiesFilesOverriddenByInlinedPropertiesTestPropertySourceTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/env/MergedPropertiesFilesOverriddenByInlinedPropertiesTestPropertySourceTests.java @@ -32,8 +32,7 @@ * @since 4.1 */ @TestPropertySource(properties = { "explicit = inlined", "extended = inlined1", "extended = inlined2" }) -// Since ExplicitPropertiesFileTestPropertySourceTests is disabled in AOT mode, this class must be also. -@DisabledInAotMode +@DisabledInAotMode("Because ExplicitPropertiesFileTestPropertySourceTests is disabled in AOT mode") class MergedPropertiesFilesOverriddenByInlinedPropertiesTestPropertySourceTests extends MergedPropertiesFilesTestPropertySourceTests { diff --git a/spring-test/src/test/java/org/springframework/test/context/env/MergedPropertiesFilesTestPropertySourceTests.java b/spring-test/src/test/java/org/springframework/test/context/env/MergedPropertiesFilesTestPropertySourceTests.java index be4f2db36aa1..023aec6037aa 100644 --- a/spring-test/src/test/java/org/springframework/test/context/env/MergedPropertiesFilesTestPropertySourceTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/env/MergedPropertiesFilesTestPropertySourceTests.java @@ -31,8 +31,7 @@ * @since 4.1 */ @TestPropertySource("extended.properties") -// Since ExplicitPropertiesFileTestPropertySourceTests is disabled in AOT mode, this class must be also. -@DisabledInAotMode +@DisabledInAotMode("Because ExplicitPropertiesFileTestPropertySourceTests is disabled in AOT mode") class MergedPropertiesFilesTestPropertySourceTests extends ExplicitPropertiesFileInClasspathTestPropertySourceTests { diff --git a/spring-test/src/test/java/org/springframework/test/context/env/subpackage/SubpackageInheritedRelativePathPropertiesFileTestPropertySourceTests.java b/spring-test/src/test/java/org/springframework/test/context/env/subpackage/SubpackageInheritedRelativePathPropertiesFileTestPropertySourceTests.java index 587ab3c7a9b3..8f2bad7290c2 100644 --- a/spring-test/src/test/java/org/springframework/test/context/env/subpackage/SubpackageInheritedRelativePathPropertiesFileTestPropertySourceTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/env/subpackage/SubpackageInheritedRelativePathPropertiesFileTestPropertySourceTests.java @@ -28,8 +28,7 @@ * @author Sam Brannen * @since 4.1 */ -// Since ExplicitPropertiesFileTestPropertySourceTests is disabled in AOT mode, this class must be also. -@DisabledInAotMode +@DisabledInAotMode("Because ExplicitPropertiesFileTestPropertySourceTests is disabled in AOT mode") class SubpackageInheritedRelativePathPropertiesFileTestPropertySourceTests extends ExplicitPropertiesFileInClasspathTestPropertySourceTests { diff --git a/spring-test/src/test/java/org/springframework/test/context/expression/ExpressionUsageTests.java b/spring-test/src/test/java/org/springframework/test/context/expression/ExpressionUsageTests.java index 6457afdec369..24a937ccdfe5 100644 --- a/spring-test/src/test/java/org/springframework/test/context/expression/ExpressionUsageTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/expression/ExpressionUsageTests.java @@ -32,7 +32,7 @@ * @author Dave Syer */ @SpringJUnitConfig -@DisabledInAotMode // SpEL is not supported in AOT +@DisabledInAotMode("SpEL is not supported in AOT") class ExpressionUsageTests { @Autowired diff --git a/spring-test/src/test/java/org/springframework/test/context/groovy/GroovyControlGroupTests.java b/spring-test/src/test/java/org/springframework/test/context/groovy/GroovyControlGroupTests.java index 02997ff5bbb8..75bdc0c37a37 100644 --- a/spring-test/src/test/java/org/springframework/test/context/groovy/GroovyControlGroupTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/groovy/GroovyControlGroupTests.java @@ -20,7 +20,7 @@ import org.springframework.beans.testfixture.beans.Employee; import org.springframework.beans.testfixture.beans.Pet; -import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.support.GenericGroovyApplicationContext; import static org.assertj.core.api.Assertions.assertThat; @@ -42,22 +42,23 @@ class GroovyControlGroupTests { @Test void verifyScriptUsingGenericGroovyApplicationContext() { - ApplicationContext ctx = new GenericGroovyApplicationContext(getClass(), "context.groovy"); + try (ConfigurableApplicationContext ctx = new GenericGroovyApplicationContext(getClass(), "context.groovy")) { + String foo = ctx.getBean("foo", String.class); + assertThat(foo).isEqualTo("Foo"); - String foo = ctx.getBean("foo", String.class); - assertThat(foo).isEqualTo("Foo"); + String bar = ctx.getBean("bar", String.class); + assertThat(bar).isEqualTo("Bar"); - String bar = ctx.getBean("bar", String.class); - assertThat(bar).isEqualTo("Bar"); + Pet pet = ctx.getBean(Pet.class); + assertThat(pet).as("pet").isNotNull(); + assertThat(pet.getName()).isEqualTo("Dogbert"); - Pet pet = ctx.getBean(Pet.class); - assertThat(pet).as("pet").isNotNull(); - assertThat(pet.getName()).isEqualTo("Dogbert"); + Employee employee = ctx.getBean(Employee.class); + assertThat(employee).as("employee").isNotNull(); + assertThat(employee.getName()).isEqualTo("Dilbert"); + assertThat(employee.getCompany()).isEqualTo("???"); + } - Employee employee = ctx.getBean(Employee.class); - assertThat(employee).as("employee").isNotNull(); - assertThat(employee.getName()).isEqualTo("Dilbert"); - assertThat(employee.getCompany()).isEqualTo("???"); } } diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/meta/MetaHierarchyLevelOneTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/meta/MetaHierarchyLevelOneTests.java index ef1bdcc228c8..8fa2a98dc058 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/meta/MetaHierarchyLevelOneTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/meta/MetaHierarchyLevelOneTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ */ @ExtendWith(SpringExtension.class) @MetaMetaContextHierarchyConfig -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class MetaHierarchyLevelOneTests { @Autowired diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/meta/MetaHierarchyLevelTwoTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/meta/MetaHierarchyLevelTwoTests.java index ca17f2cce4b4..f52b92e26a9e 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/meta/MetaHierarchyLevelTwoTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/meta/MetaHierarchyLevelTwoTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +35,7 @@ */ @ContextConfiguration @ActiveProfiles("prod") -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class MetaHierarchyLevelTwoTests extends MetaHierarchyLevelOneTests { @Configuration diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/ClassHierarchyWithMergedConfigLevelOneTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/ClassHierarchyWithMergedConfigLevelOneTests.java index 1b62adbcbc58..1272dad8eab0 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/ClassHierarchyWithMergedConfigLevelOneTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/ClassHierarchyWithMergedConfigLevelOneTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,7 +41,7 @@ @ContextConfiguration(name = "parent", classes = ClassHierarchyWithMergedConfigLevelOneTests.AppConfig.class),// @ContextConfiguration(name = "child", classes = ClassHierarchyWithMergedConfigLevelOneTests.UserConfig.class) // }) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class ClassHierarchyWithMergedConfigLevelOneTests { @Configuration diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/ClassHierarchyWithMergedConfigLevelTwoTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/ClassHierarchyWithMergedConfigLevelTwoTests.java index 94c9fea02f49..61d0c7912021 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/ClassHierarchyWithMergedConfigLevelTwoTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/ClassHierarchyWithMergedConfigLevelTwoTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +35,7 @@ */ @ExtendWith(SpringExtension.class) @ContextHierarchy(@ContextConfiguration(name = "child", classes = ClassHierarchyWithMergedConfigLevelTwoTests.OrderConfig.class)) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class ClassHierarchyWithMergedConfigLevelTwoTests extends ClassHierarchyWithMergedConfigLevelOneTests { @Configuration diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/ClassHierarchyWithOverriddenConfigLevelTwoTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/ClassHierarchyWithOverriddenConfigLevelTwoTests.java index 33d66b9759b3..e0641a9e52f0 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/ClassHierarchyWithOverriddenConfigLevelTwoTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/ClassHierarchyWithOverriddenConfigLevelTwoTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +35,7 @@ */ @ExtendWith(SpringExtension.class) @ContextHierarchy(@ContextConfiguration(name = "child", classes = ClassHierarchyWithOverriddenConfigLevelTwoTests.TestUserConfig.class, inheritLocations = false)) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class ClassHierarchyWithOverriddenConfigLevelTwoTests extends ClassHierarchyWithMergedConfigLevelOneTests { @Configuration diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/DirtiesContextWithContextHierarchyTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/DirtiesContextWithContextHierarchyTests.java index a26abdc1f511..1d0d9d1891c3 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/DirtiesContextWithContextHierarchyTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/DirtiesContextWithContextHierarchyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,7 +52,7 @@ @ContextConfiguration(classes = DirtiesContextWithContextHierarchyTests.ChildConfig.class) }) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class DirtiesContextWithContextHierarchyTests { @Autowired diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/SingleTestClassWithSingleLevelContextHierarchyTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/SingleTestClassWithSingleLevelContextHierarchyTests.java index f4cef5cfd60c..b4ad89a74fd9 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/SingleTestClassWithSingleLevelContextHierarchyTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/SingleTestClassWithSingleLevelContextHierarchyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ */ @ExtendWith(SpringExtension.class) @ContextHierarchy(@ContextConfiguration) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class SingleTestClassWithSingleLevelContextHierarchyTests { @Configuration diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/SingleTestClassWithTwoLevelContextHierarchyAndMixedConfigTypesTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/SingleTestClassWithTwoLevelContextHierarchyAndMixedConfigTypesTests.java index 3e636322374c..025fcb8aab05 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/SingleTestClassWithTwoLevelContextHierarchyAndMixedConfigTypesTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/SingleTestClassWithTwoLevelContextHierarchyAndMixedConfigTypesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,7 +38,7 @@ @ContextHierarchy({ @ContextConfiguration(classes = SingleTestClassWithTwoLevelContextHierarchyAndMixedConfigTypesTests.ParentConfig.class), @ContextConfiguration("SingleTestClassWithTwoLevelContextHierarchyAndMixedConfigTypesTests-ChildConfig.xml") }) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class SingleTestClassWithTwoLevelContextHierarchyAndMixedConfigTypesTests { @Configuration diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/SingleTestClassWithTwoLevelContextHierarchyTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/SingleTestClassWithTwoLevelContextHierarchyTests.java index 0a24897ef075..e1056aaa64c3 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/SingleTestClassWithTwoLevelContextHierarchyTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/SingleTestClassWithTwoLevelContextHierarchyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,7 +38,7 @@ @ContextHierarchy({ @ContextConfiguration(classes = SingleTestClassWithTwoLevelContextHierarchyTests.ParentConfig.class), @ContextConfiguration(classes = SingleTestClassWithTwoLevelContextHierarchyTests.ChildConfig.class) }) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") public class SingleTestClassWithTwoLevelContextHierarchyTests { @Configuration diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelOneWithBareContextConfigurationInSubclassTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelOneWithBareContextConfigurationInSubclassTests.java index 377fab4a8cb8..00fac2616324 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelOneWithBareContextConfigurationInSubclassTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelOneWithBareContextConfigurationInSubclassTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ */ @ExtendWith(SpringExtension.class) @ContextHierarchy(@ContextConfiguration) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class TestHierarchyLevelOneWithBareContextConfigurationInSubclassTests { @Configuration diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelOneWithSingleLevelContextHierarchyTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelOneWithSingleLevelContextHierarchyTests.java index c93aeba76d17..7e54952cbe71 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelOneWithSingleLevelContextHierarchyTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelOneWithSingleLevelContextHierarchyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ */ @ExtendWith(SpringExtension.class) @ContextHierarchy(@ContextConfiguration) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class TestHierarchyLevelOneWithSingleLevelContextHierarchyTests { @Configuration diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithBareContextConfigurationInSubclassTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithBareContextConfigurationInSubclassTests.java index fed405f131c7..cea9c463924f 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithBareContextConfigurationInSubclassTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithBareContextConfigurationInSubclassTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +35,7 @@ */ @ExtendWith(SpringExtension.class) @ContextConfiguration -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class TestHierarchyLevelTwoWithBareContextConfigurationInSubclassTests extends TestHierarchyLevelOneWithBareContextConfigurationInSubclassTests { diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithBareContextConfigurationInSuperclassTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithBareContextConfigurationInSuperclassTests.java index 3504720b1960..4b8224399e76 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithBareContextConfigurationInSuperclassTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithBareContextConfigurationInSuperclassTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ */ @ExtendWith(SpringExtension.class) @ContextHierarchy(@ContextConfiguration) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class TestHierarchyLevelTwoWithBareContextConfigurationInSuperclassTests extends TestHierarchyLevelOneWithBareContextConfigurationInSuperclassTests { diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithSingleLevelContextHierarchyAndMixedConfigTypesTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithSingleLevelContextHierarchyAndMixedConfigTypesTests.java index c653e5e7ebc5..10a9e69d5677 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithSingleLevelContextHierarchyAndMixedConfigTypesTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithSingleLevelContextHierarchyAndMixedConfigTypesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ */ @ExtendWith(SpringExtension.class) @ContextHierarchy(@ContextConfiguration) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class TestHierarchyLevelTwoWithSingleLevelContextHierarchyAndMixedConfigTypesTests extends TestHierarchyLevelOneWithSingleLevelContextHierarchyTests { diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithSingleLevelContextHierarchyTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithSingleLevelContextHierarchyTests.java index 74108bd5ba99..c5b4b53655c6 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithSingleLevelContextHierarchyTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/standard/TestHierarchyLevelTwoWithSingleLevelContextHierarchyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ */ @ExtendWith(SpringExtension.class) @ContextHierarchy(@ContextConfiguration) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class TestHierarchyLevelTwoWithSingleLevelContextHierarchyTests extends TestHierarchyLevelOneWithSingleLevelContextHierarchyTests { diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/web/ControllerIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/web/ControllerIntegrationTests.java index 0e08ddcc2422..6deda78f37dd 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/web/ControllerIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/web/ControllerIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +45,7 @@ @ContextConfiguration(name = "root", classes = AppConfig.class), @ContextConfiguration(name = "dispatcher", classes = WebConfig.class) }) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class ControllerIntegrationTests { @Configuration diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/web/DispatcherWacRootWacEarTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/web/DispatcherWacRootWacEarTests.java index 9a7f556fbbe5..94fd28839030 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/web/DispatcherWacRootWacEarTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/web/DispatcherWacRootWacEarTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ * @since 3.2.2 */ @ContextHierarchy(@ContextConfiguration) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") public class DispatcherWacRootWacEarTests extends RootWacEarTests { @Autowired diff --git a/spring-test/src/test/java/org/springframework/test/context/hierarchies/web/RootWacEarTests.java b/spring-test/src/test/java/org/springframework/test/context/hierarchies/web/RootWacEarTests.java index 75cca14a75c9..b0b2e22ffbd4 100644 --- a/spring-test/src/test/java/org/springframework/test/context/hierarchies/web/RootWacEarTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/hierarchies/web/RootWacEarTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +37,7 @@ */ @WebAppConfiguration @ContextHierarchy(@ContextConfiguration) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class RootWacEarTests extends EarTests { @Configuration(proxyBeanMethods = false) diff --git a/spring-test/src/test/java/org/springframework/test/context/jdbc/PropertyPlaceholderSqlScriptsTests.java b/spring-test/src/test/java/org/springframework/test/context/jdbc/PropertyPlaceholderSqlScriptsTests.java new file mode 100644 index 000000000000..f18d58c3bcaa --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/jdbc/PropertyPlaceholderSqlScriptsTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.jdbc; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.aot.DisabledInAotMode; + +/** + * Integration tests that verify support for property placeholders in SQL script locations. + * + * @author Sam Brannen + * @since 6.2 + */ +class PropertyPlaceholderSqlScriptsTests { + + private static final String SCRIPT_LOCATION = "classpath:org/springframework/test/context/jdbc/${vendor}/data.sql"; + + @Nested + @ContextConfiguration(classes = PopulatedSchemaDatabaseConfig.class) + @TestPropertySource(properties = "vendor = db1") + @DirtiesContext + @DisabledInAotMode("${vendor} does not get resolved during AOT processing") + class DatabaseOneTests extends AbstractTransactionalTests { + + @Test + @Sql(SCRIPT_LOCATION) + void placeholderIsResolvedInScriptLocation() { + assertUsers("Dilbert 1"); + } + } + + @Nested + @ContextConfiguration(classes = PopulatedSchemaDatabaseConfig.class) + @TestPropertySource(properties = "vendor = db2") + @DirtiesContext + @DisabledInAotMode("${vendor} does not get resolved during AOT processing") + class DatabaseTwoTests extends AbstractTransactionalTests { + + @Test + @Sql(SCRIPT_LOCATION) + void placeholderIsResolvedInScriptLocation() { + assertUsers("Dilbert 2"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/jdbc/SqlScriptsTestExecutionListenerTests.java b/spring-test/src/test/java/org/springframework/test/context/jdbc/SqlScriptsTestExecutionListenerTests.java index 38dd0f77513e..b14cadb2f4c9 100644 --- a/spring-test/src/test/java/org/springframework/test/context/jdbc/SqlScriptsTestExecutionListenerTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/jdbc/SqlScriptsTestExecutionListenerTests.java @@ -21,6 +21,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.core.annotation.AnnotationConfigurationException; +import org.springframework.mock.env.MockEnvironment; import org.springframework.test.context.TestContext; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -84,6 +85,7 @@ void isolatedTxModeDeclaredWithoutTxMgr() throws Exception { ApplicationContext ctx = mock(); given(ctx.getResource(anyString())).willReturn(mock()); given(ctx.getAutowireCapableBeanFactory()).willReturn(mock()); + given(ctx.getEnvironment()).willReturn(new MockEnvironment()); Class clazz = IsolatedWithoutTxMgr.class; BDDMockito.> given(testContext.getTestClass()).willReturn(clazz); @@ -98,6 +100,7 @@ void missingDataSourceAndTxMgr() throws Exception { ApplicationContext ctx = mock(); given(ctx.getResource(anyString())).willReturn(mock()); given(ctx.getAutowireCapableBeanFactory()).willReturn(mock()); + given(ctx.getEnvironment()).willReturn(new MockEnvironment()); Class clazz = MissingDataSourceAndTxMgr.class; BDDMockito.> given(testContext.getTestClass()).willReturn(clazz); diff --git a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/AutowiredConfigurationErrorsIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/AutowiredConfigurationErrorsIntegrationTests.java index 919e0b841b3a..11fa6eb0fa41 100644 --- a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/AutowiredConfigurationErrorsIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/AutowiredConfigurationErrorsIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +65,11 @@ class AutowiredConfigurationErrorsIntegrationTests { @ParameterizedTest @ValueSource(classes = { StaticAutowiredBeforeAllMethod.class, + StaticAutowiredPrivateBeforeAllMethod.class, StaticAutowiredAfterAllMethod.class, AutowiredBeforeEachMethod.class, AutowiredAfterEachMethod.class, + AutowiredPrivateAfterEachMethod.class, AutowiredTestMethod.class, AutowiredRepeatedTestMethod.class, AutowiredParameterizedTestMethod.class @@ -166,6 +168,21 @@ void test() { } } + @SpringJUnitConfig(Config.class) + @FailingTestCase + static class StaticAutowiredPrivateBeforeAllMethod { + + @Autowired + @BeforeAll + private static void beforeAll(TestInfo testInfo) { + } + + @Test + @DisplayName(DISPLAY_NAME) + void test() { + } + } + @SpringJUnitConfig(Config.class) @TestInstance(PER_CLASS) @FailingTestCase @@ -243,6 +260,22 @@ void afterEach(TestInfo testInfo) { } } + @SpringJUnitConfig(Config.class) + @FailingTestCase + static class AutowiredPrivateAfterEachMethod { + + @Test + @DisplayName(DISPLAY_NAME) + void test() { + } + + @Autowired + @AfterEach + private void afterEach(TestInfo testInfo) { + } + } + + @SpringJUnitConfig(Config.class) @FailingTestCase static class AutowiredTestMethod { diff --git a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/nested/ContextHierarchyNestedTests.java b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/nested/ContextHierarchyNestedTests.java index 21809f1ac925..6e0814c474db 100644 --- a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/nested/ContextHierarchyNestedTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/nested/ContextHierarchyNestedTests.java @@ -47,7 +47,7 @@ @ExtendWith(SpringExtension.class) @ContextHierarchy(@ContextConfiguration(classes = ParentConfig.class)) @NestedTestConfiguration(OVERRIDE) // since INHERIT is now the global default -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class ContextHierarchyNestedTests { private static final String FOO = "foo"; diff --git a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/nested/TestExecutionListenersNestedTests.java b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/nested/TestExecutionListenersNestedTests.java index 0d94834ef80b..9194161e865b 100644 --- a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/nested/TestExecutionListenersNestedTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/nested/TestExecutionListenersNestedTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -47,9 +47,7 @@ @ExtendWith(SpringExtension.class) @TestExecutionListeners(FooTestExecutionListener.class) @NestedTestConfiguration(OVERRIDE) // since INHERIT is now the global default -// Since this test class does not load an ApplicationContext, -// this test class simply is not supported for AOT processing. -@DisabledInAotMode +@DisabledInAotMode("Does not load an ApplicationContext and thus not supported for AOT processing") class TestExecutionListenersNestedTests { private static final String FOO = "foo"; @@ -83,7 +81,7 @@ void test() { @Nested @TestExecutionListeners(BarTestExecutionListener.class) - @DisabledInAotMode + @DisabledInAotMode("Does not load an ApplicationContext and thus not supported for AOT processing") class ConfigOverriddenByDefaultTests { @Test @@ -106,7 +104,7 @@ void test() { @Nested @NestedTestConfiguration(OVERRIDE) @TestExecutionListeners(BazTestExecutionListener.class) - @DisabledInAotMode + @DisabledInAotMode("Does not load an ApplicationContext and thus not supported for AOT processing") class DoubleNestedWithOverriddenConfigTests { @Test diff --git a/spring-test/src/test/java/org/springframework/test/context/junit4/ClassLevelDisabledSpringRunnerTests.java b/spring-test/src/test/java/org/springframework/test/context/junit4/ClassLevelDisabledSpringRunnerTests.java index 1e6762a7feab..60938afcb5bf 100644 --- a/spring-test/src/test/java/org/springframework/test/context/junit4/ClassLevelDisabledSpringRunnerTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/junit4/ClassLevelDisabledSpringRunnerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +34,7 @@ @RunWith(SpringRunner.class) @TestExecutionListeners(ClassLevelDisabledSpringRunnerTests.CustomTestExecutionListener.class) @IfProfileValue(name = "ClassLevelDisabledSpringRunnerTests.profile_value.name", value = "enigmaX") -// Since Spring test's AOT processing support does not evaluate @IfProfileValue, -// this test class simply is not supported for AOT processing. -@DisabledInAotMode +@DisabledInAotMode("@IfProfileValue is not supported for AOT processing") public class ClassLevelDisabledSpringRunnerTests { @Test diff --git a/spring-test/src/test/java/org/springframework/test/context/junit4/ClassPathResourceSpringJUnit4ClassRunnerAppCtxTests.java b/spring-test/src/test/java/org/springframework/test/context/junit4/ClassPathResourceSpringJUnit4ClassRunnerAppCtxTests.java index 2cc475b65cc2..869664fe0b9e 100644 --- a/spring-test/src/test/java/org/springframework/test/context/junit4/ClassPathResourceSpringJUnit4ClassRunnerAppCtxTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/junit4/ClassPathResourceSpringJUnit4ClassRunnerAppCtxTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2012 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ * @see AbsolutePathSpringJUnit4ClassRunnerAppCtxTests * @see RelativePathSpringJUnit4ClassRunnerAppCtxTests */ -@ContextConfiguration(locations = { ClassPathResourceSpringJUnit4ClassRunnerAppCtxTests.CLASSPATH_CONTEXT_RESOURCE_PATH }) +@ContextConfiguration(locations = { ClassPathResourceSpringJUnit4ClassRunnerAppCtxTests.CLASSPATH_CONTEXT_RESOURCE_PATH }, inheritLocations = false) public class ClassPathResourceSpringJUnit4ClassRunnerAppCtxTests extends SpringJUnit4ClassRunnerAppCtxTests { /** diff --git a/spring-test/src/test/java/org/springframework/test/context/junit4/EnabledAndIgnoredSpringRunnerTests.java b/spring-test/src/test/java/org/springframework/test/context/junit4/EnabledAndIgnoredSpringRunnerTests.java index bb8c8ac359b7..52cbface38de 100644 --- a/spring-test/src/test/java/org/springframework/test/context/junit4/EnabledAndIgnoredSpringRunnerTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/junit4/EnabledAndIgnoredSpringRunnerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +48,7 @@ */ @RunWith(SpringRunner.class) @TestExecutionListeners({}) -// Since this test class does not load an ApplicationContext, -// this test class simply is not supported for AOT processing. -@DisabledInAotMode +@DisabledInAotMode("Does not load an ApplicationContext and thus not supported for AOT processing") public class EnabledAndIgnoredSpringRunnerTests { protected static final String NAME = "EnabledAndIgnoredSpringRunnerTests.profile_value.name"; diff --git a/spring-test/src/test/java/org/springframework/test/context/junit4/HardCodedProfileValueSourceSpringRunnerTests.java b/spring-test/src/test/java/org/springframework/test/context/junit4/HardCodedProfileValueSourceSpringRunnerTests.java index d75577697f34..7c5fec7dc6d5 100644 --- a/spring-test/src/test/java/org/springframework/test/context/junit4/HardCodedProfileValueSourceSpringRunnerTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/junit4/HardCodedProfileValueSourceSpringRunnerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 +37,7 @@ * @see EnabledAndIgnoredSpringRunnerTests */ @ProfileValueSourceConfiguration(HardCodedProfileValueSourceSpringRunnerTests.HardCodedProfileValueSource.class) -// Since EnabledAndIgnoredSpringRunnerTests is disabled in AOT mode, this test class must be also. -@DisabledInAotMode +@DisabledInAotMode("Because EnabledAndIgnoredSpringRunnerTests is disabled in AOT mode") public class HardCodedProfileValueSourceSpringRunnerTests extends EnabledAndIgnoredSpringRunnerTests { @BeforeClass diff --git a/spring-test/src/test/java/org/springframework/test/context/junit4/SpringJUnit47ClassRunnerRuleTests.java b/spring-test/src/test/java/org/springframework/test/context/junit4/SpringJUnit47ClassRunnerRuleTests.java index d7a46ce24c73..92005176dc57 100644 --- a/spring-test/src/test/java/org/springframework/test/context/junit4/SpringJUnit47ClassRunnerRuleTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/junit4/SpringJUnit47ClassRunnerRuleTests.java @@ -37,9 +37,7 @@ */ @RunWith(SpringRunner.class) @TestExecutionListeners({}) -// Since this test class does not load an ApplicationContext, -// this test class simply is not supported for AOT processing. -@DisabledInAotMode +@DisabledInAotMode("Does not load an ApplicationContext and thus not supported for AOT processing") public class SpringJUnit47ClassRunnerRuleTests { @Rule diff --git a/spring-test/src/test/java/org/springframework/test/context/junit4/StandardJUnit4FeaturesSpringRunnerTests.java b/spring-test/src/test/java/org/springframework/test/context/junit4/StandardJUnit4FeaturesSpringRunnerTests.java index bf995fe8bb21..0535382b2528 100644 --- a/spring-test/src/test/java/org/springframework/test/context/junit4/StandardJUnit4FeaturesSpringRunnerTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/junit4/StandardJUnit4FeaturesSpringRunnerTests.java @@ -38,9 +38,7 @@ */ @RunWith(SpringRunner.class) @TestExecutionListeners({}) -// Since this test class does not load an ApplicationContext, -// this test class simply is not supported for AOT processing. -@DisabledInAotMode +@DisabledInAotMode("Does not load an ApplicationContext and thus not supported for AOT processing") public class StandardJUnit4FeaturesSpringRunnerTests extends StandardJUnit4FeaturesTests { /* All tests are in the parent class... */ diff --git a/spring-test/src/test/java/org/springframework/test/context/junit4/spr9051/TransactionalAnnotatedConfigClassesWithoutAtConfigurationTests.java b/spring-test/src/test/java/org/springframework/test/context/junit4/spr9051/TransactionalAnnotatedConfigClassesWithoutAtConfigurationTests.java index e13dd93c80a5..7914ac6aff97 100644 --- a/spring-test/src/test/java/org/springframework/test/context/junit4/spr9051/TransactionalAnnotatedConfigClassesWithoutAtConfigurationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/junit4/spr9051/TransactionalAnnotatedConfigClassesWithoutAtConfigurationTests.java @@ -73,9 +73,9 @@ public PlatformTransactionManager transactionManager() { /** * Since this method does not reside in a true {@code @Configuration class}, - * it acts as a factory method when invoked directly (e.g., from + * it acts as a factory method when invoked directly (for example, from * {@link #transactionManager()}) and as a singleton bean when retrieved - * through the application context (e.g., when injected into the test + * through the application context (for example, when injected into the test * instance). The result is that this method will be called twice: * *

      diff --git a/spring-test/src/test/java/org/springframework/test/context/support/BootstrapTestUtilsMergedConfigTests.java b/spring-test/src/test/java/org/springframework/test/context/support/BootstrapTestUtilsMergedConfigTests.java index 5dd24e7ebef0..6345184bbbe9 100644 --- a/spring-test/src/test/java/org/springframework/test/context/support/BootstrapTestUtilsMergedConfigTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/support/BootstrapTestUtilsMergedConfigTests.java @@ -36,7 +36,6 @@ import org.springframework.test.context.web.WebMergedContextConfiguration; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** * Tests for {@link BootstrapTestUtils} involving {@link MergedContextConfiguration}. @@ -59,10 +58,14 @@ void buildImplicitMergedConfigWithoutAnnotation() { */ @Test void buildMergedConfigWithContextConfigurationWithoutLocationsClassesOrInitializers() { - assertThatIllegalStateException().isThrownBy(() -> - buildMergedContextConfiguration(MissingContextAttributesTestCase.class)) - .withMessageStartingWith("DelegatingSmartContextLoader was unable to detect defaults, " - + "and no ApplicationContextInitializers or ContextCustomizers were declared for context configuration attributes"); + Class testClass = MissingContextAttributesTestCase.class; + MergedContextConfiguration mergedConfig = buildMergedContextConfiguration(testClass); + + assertMergedConfig(mergedConfig, testClass, EMPTY_STRING_ARRAY, EMPTY_CLASS_ARRAY, DelegatingSmartContextLoader.class); + assertThat(mergedConfig.getContextCustomizers()) + .map(Object::getClass) + .map(Class::getSimpleName) + .containsOnly("DynamicPropertiesContextCustomizer"); } @Test diff --git a/spring-test/src/test/java/org/springframework/test/context/support/CommonCachesTestExecutionListenerIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/support/CommonCachesTestExecutionListenerIntegrationTests.java new file mode 100644 index 000000000000..7a4bbbebb4c6 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/support/CommonCachesTestExecutionListenerIntegrationTests.java @@ -0,0 +1,90 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.support; + +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.core.io.ResourceLoader; +import org.springframework.core.type.classreading.MetadataReader; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.support.samples.SampleComponent; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests that verify that common caches are cleared at the end of a test + * class. Regular callbacks cannot be used to validate this as they run + * before the listener, so we need two test classes that are ordered to + * validate the result. + * + * @author Stephane Nicoll + */ +@SpringJUnitConfig +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +class CommonCachesTestExecutionListenerIntegrationTests { + + @Autowired + AbstractApplicationContext applicationContext; + + @Nested + @Order(1) + class FirstTests { + + @Test + void lazyInitBeans() { + assertThat(applicationContext.getBean(String.class)).isEqualTo("Dummy"); + assertThat(applicationContext.getResourceCache(MetadataReader.class)).isNotEmpty(); + } + + } + + @Nested + @Order(2) + class SecondTests { + + @Test + void validateCommonCacheIsCleared() { + assertThat(applicationContext.getResourceCache(MetadataReader.class)).isEmpty(); + } + + } + + + @Configuration + static class TestConfiguration { + + @Bean + @Lazy + String dummyBean(ResourceLoader resourceLoader) { + ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(true); + scanner.setResourceLoader(resourceLoader); + scanner.findCandidateComponents(SampleComponent.class.getPackageName()); + return "Dummy"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/support/CommonCachesTestExecutionListenerTests.java b/spring-test/src/test/java/org/springframework/test/context/support/CommonCachesTestExecutionListenerTests.java new file mode 100644 index 000000000000..49ba9afa2339 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/support/CommonCachesTestExecutionListenerTests.java @@ -0,0 +1,57 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.support; + +import org.junit.jupiter.api.Test; + +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.test.context.TestContext; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * Tests for {@link CommonCachesTestExecutionListener}. + * + * @author Stephane Nicoll + */ +class CommonCachesTestExecutionListenerTests { + + private final CommonCachesTestExecutionListener listener = new CommonCachesTestExecutionListener(); + + @Test + void afterTestClassWhenContextIsAvailable() throws Exception { + AbstractApplicationContext applicationContext = mock(); + TestContext testContext = mock(TestContext.class); + given(testContext.hasApplicationContext()).willReturn(true); + given(testContext.getApplicationContext()).willReturn(applicationContext); + listener.afterTestClass(testContext); + verify(applicationContext).clearResourceCaches(); + } + + @Test + void afterTestClassCWhenContextIsNotAvailable() throws Exception { + TestContext testContext = mock(); + given(testContext.hasApplicationContext()).willReturn(false); + listener.afterTestClass(testContext); + verify(testContext).hasApplicationContext(); + verifyNoMoreInteractions(testContext); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/support/DefaultTestPropertySourcesIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/support/DefaultTestPropertySourcesIntegrationTests.java new file mode 100644 index 000000000000..ccda9d355773 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/support/DefaultTestPropertySourcesIntegrationTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.support; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests which ensure that test-related property sources are not + * registered by default. + * + * @author Sam Brannen + * @since 6.2 + */ +@SpringJUnitConfig +@DirtiesContext +class DefaultTestPropertySourcesIntegrationTests { + + @Autowired + ConfigurableEnvironment env; + + + @Test + void ensureTestRelatedPropertySourcesAreNotRegisteredByDefault() { + assertPropertySourceIsNotRegistered(TestPropertySourceUtils.INLINED_PROPERTIES_PROPERTY_SOURCE_NAME); + assertPropertySourceIsNotRegistered(DynamicValuesPropertySource.PROPERTY_SOURCE_NAME); + } + + private void assertPropertySourceIsNotRegistered(String name) { + MutablePropertySources propertySources = this.env.getPropertySources(); + assertThat(propertySources.contains(name)) + .as("PropertySource \"%s\" should not be registered by default", name) + .isFalse(); + } + + + @Configuration + static class Config { + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/support/DynamicPropertiesContextCustomizerFactoryTests.java b/spring-test/src/test/java/org/springframework/test/context/support/DynamicPropertiesContextCustomizerFactoryTests.java index 4f58a1b65b15..422111859179 100644 --- a/spring-test/src/test/java/org/springframework/test/context/support/DynamicPropertiesContextCustomizerFactoryTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/support/DynamicPropertiesContextCustomizerFactoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -40,10 +40,11 @@ class DynamicPropertiesContextCustomizerFactoryTests { private final List configAttributes = Collections.emptyList(); @Test - void createContextCustomizerWhenNoAnnotatedMethodsReturnsNull() { + void createContextCustomizerWhenNoAnnotatedMethodsReturnsCustomizerWithEmptyMethods() { DynamicPropertiesContextCustomizer customizer = this.factory.createContextCustomizer( NoDynamicPropertySource.class, this.configAttributes); - assertThat(customizer).isNull(); + assertThat(customizer).isNotNull(); + assertThat(customizer.getMethods()).isEmpty(); } @Test diff --git a/spring-test/src/test/java/org/springframework/test/context/support/DynamicValuesPropertySourceTests.java b/spring-test/src/test/java/org/springframework/test/context/support/DynamicValuesPropertySourceTests.java index 065486b2a27f..7f75fe927ebf 100644 --- a/spring-test/src/test/java/org/springframework/test/context/support/DynamicValuesPropertySourceTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/support/DynamicValuesPropertySourceTests.java @@ -30,7 +30,7 @@ */ class DynamicValuesPropertySourceTests { - private final DynamicValuesPropertySource source = new DynamicValuesPropertySource("test", + private final DynamicValuesPropertySource source = new DynamicValuesPropertySource( Map.of("a", () -> "A", "b", () -> "B")); diff --git a/spring-test/src/test/java/org/springframework/test/context/support/GenericXmlContextLoaderResourceLocationsTests.java b/spring-test/src/test/java/org/springframework/test/context/support/GenericXmlContextLoaderResourceLocationsTests.java index af36e79fb8fc..02b2038fed29 100644 --- a/spring-test/src/test/java/org/springframework/test/context/support/GenericXmlContextLoaderResourceLocationsTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/support/GenericXmlContextLoaderResourceLocationsTests.java @@ -30,8 +30,7 @@ import org.springframework.util.ObjectUtils; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Named.named; -import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.junit.jupiter.params.provider.Arguments.argumentSet; /** * Unit test which verifies proper @@ -51,7 +50,7 @@ class GenericXmlContextLoaderResourceLocationsTests { private static final Log logger = LogFactory.getLog(GenericXmlContextLoaderResourceLocationsTests.class); - @ParameterizedTest(name = "[{index}] {0}") + @ParameterizedTest @MethodSource("contextConfigurationLocationsData") void assertContextConfigurationLocations(Class testClass, String[] expectedLocations) { ContextConfiguration contextConfig = testClass.getAnnotation(ContextConfiguration.class); @@ -98,7 +97,7 @@ static Stream contextConfigurationLocationsData() { } private static Arguments args(Class testClass, String[] expectedLocations) { - return arguments(named(testClass.getSimpleName(), testClass), expectedLocations); + return argumentSet(testClass.getSimpleName(), testClass, expectedLocations); } private static String[] array(String... elements) { diff --git a/spring-test/src/test/java/org/springframework/test/context/support/samples/SampleComponent.java b/spring-test/src/test/java/org/springframework/test/context/support/samples/SampleComponent.java new file mode 100644 index 000000000000..9dc3cdce24a2 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/support/samples/SampleComponent.java @@ -0,0 +1,26 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.support.samples; + +import org.springframework.stereotype.Component; + +/** + * Intended to be picked up by component scanning in tests in the support package. + */ +@Component +public class SampleComponent { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/CommitForRequiredEjbTxDaoTests.java b/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/CommitForRequiredEjbTxDaoTests.java index e41f0ab34623..70571386e68e 100644 --- a/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/CommitForRequiredEjbTxDaoTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/CommitForRequiredEjbTxDaoTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +33,7 @@ */ @ContextConfiguration("required-tx-config.xml") @Commit -@DisabledInAotMode // @EJB is not supported in Spring AOT +@DisabledInAotMode("@EJB is not supported in Spring AOT") class CommitForRequiredEjbTxDaoTests extends AbstractEjbTxDaoTests { /* test methods in superclass */ diff --git a/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/CommitForRequiresNewEjbTxDaoTests.java b/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/CommitForRequiresNewEjbTxDaoTests.java index f99dfd882e2c..cea563d5997f 100644 --- a/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/CommitForRequiresNewEjbTxDaoTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/CommitForRequiresNewEjbTxDaoTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +33,7 @@ */ @ContextConfiguration("requires-new-tx-config.xml") @Commit -@DisabledInAotMode // @EJB is not supported in Spring AOT +@DisabledInAotMode("@EJB is not supported in Spring AOT") class CommitForRequiresNewEjbTxDaoTests extends AbstractEjbTxDaoTests { /* test methods in superclass */ diff --git a/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/RollbackForRequiredEjbTxDaoTests.java b/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/RollbackForRequiredEjbTxDaoTests.java index 7b5169be77ac..e1adabdd8d30 100644 --- a/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/RollbackForRequiredEjbTxDaoTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/RollbackForRequiredEjbTxDaoTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 4.0.1 */ @Rollback -@DisabledInAotMode // @EJB is not supported in Spring AOT +@DisabledInAotMode("@EJB is not supported in Spring AOT") class RollbackForRequiredEjbTxDaoTests extends CommitForRequiredEjbTxDaoTests { /** diff --git a/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/RollbackForRequiresNewEjbTxDaoTests.java b/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/RollbackForRequiresNewEjbTxDaoTests.java index deea771c1645..a68fc8a94b4d 100644 --- a/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/RollbackForRequiresNewEjbTxDaoTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/transaction/ejb/RollbackForRequiresNewEjbTxDaoTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 4.0.1 */ @Rollback -@DisabledInAotMode // @EJB is not supported in Spring AOT +@DisabledInAotMode("@EJB is not supported in Spring AOT") class RollbackForRequiresNewEjbTxDaoTests extends CommitForRequiresNewEjbTxDaoTests { /* test methods in superclass */ diff --git a/spring-test/src/test/java/org/springframework/test/http/HttpHeadersAssertTests.java b/spring-test/src/test/java/org/springframework/test/http/HttpHeadersAssertTests.java new file mode 100644 index 000000000000..7f8eca4566e5 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/http/HttpHeadersAssertTests.java @@ -0,0 +1,209 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link HttpHeadersAssert}. + * + * @author Stephane Nicoll + */ +class HttpHeadersAssertTests { + + @Test + void containsHeader() { + assertThat(Map.of("first", "1")).containsHeader("first"); + } + + @Test + void containsHeaderWithNameNotPresent() { + Map map = Map.of("first", "1"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).containsHeader("wrong-name")) + .withMessageContainingAll("HTTP headers", "first", "wrong-name"); + } + + @Test + void containsHeaders() { + assertThat(Map.of("first", "1", "second", "2", "third", "3")) + .containsHeaders("first", "third"); + } + + @Test + void containsHeadersWithSeveralNamesNotPresent() { + Map map = Map.of("first", "1", "second", "2", "third", "3"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).containsHeaders("first", "wrong-name", "another-wrong-name", "third")) + .withMessageContainingAll("HTTP headers", "first", "wrong-name", "another-wrong-name"); + } + + @Test + void doesNotContainHeader() { + assertThat(Map.of("first", "1")).doesNotContainHeader("second"); + } + + @Test + void doesNotContainHeaderWithNamePresent() { + Map map = Map.of("first", "1"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).doesNotContainKey("first")) + .withMessageContainingAll("HTTP headers", "first"); + } + + @Test + void doesNotContainHeaders() { + assertThat(Map.of("first", "1", "third", "3")) + .doesNotContainHeaders("second", "fourth"); + } + + @Test + void doesNotContainHeadersWithSeveralNamesPresent() { + Map map = Map.of("first", "1", "second", "2", "third", "3"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).doesNotContainHeaders("first", "another-wrong-name", "second")) + .withMessageContainingAll("HTTP headers", "first", "second"); + } + + @Test + @Deprecated(forRemoval = true) + @SuppressWarnings("removal") + void doesNotContainHeadersWithDeprecatedMethod() { + assertThat(Map.of("first", "1", "third", "3")) + .doesNotContainsHeaders("second", "fourth"); + } + + @Test + @Deprecated(forRemoval = true) + @SuppressWarnings("removal") + void doesNotContainHeadersWithSeveralNamesPresentWithDeprecatedMethod() { + Map map = Map.of("first", "1", "second", "2", "third", "3"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(map).doesNotContainsHeaders("first", "another-wrong-name", "second")) + .withMessageContainingAll("HTTP headers", "first", "second"); + } + + + @Test + void hasValueWithStringMatch() { + HttpHeaders headers = new HttpHeaders(); + headers.addAll("header", List.of("a", "b", "c")); + assertThat(headers).hasValue("header", "a"); + } + + @Test + void hasValueWithStringMatchOnSecondaryValue() { + HttpHeaders headers = new HttpHeaders(); + headers.addAll("header", List.of("first", "second", "third")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(headers).hasValue("header", "second")) + .withMessageContainingAll("check primary value for HTTP header 'header'", "first", "second"); + } + + @Test + void hasValueWithNoStringMatch() { + HttpHeaders headers = new HttpHeaders(); + headers.addAll("header", List.of("first", "second", "third")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(headers).hasValue("wrong-name", "second")) + .withMessageContainingAll("HTTP headers", "header", "wrong-name"); + } + + @Test + void hasValueWithNonPresentHeader() { + HttpHeaders headers = new HttpHeaders(); + headers.add("test-header", "a"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(headers).hasValue("wrong-name", "a")) + .withMessageContainingAll("HTTP headers", "test-header", "wrong-name"); + } + + @Test + void hasValueWithLongMatch() { + HttpHeaders headers = new HttpHeaders(); + headers.addAll("header", List.of("123", "456", "789")); + assertThat(headers).hasValue("header", 123); + } + + @Test + void hasValueWithLongMatchOnSecondaryValue() { + HttpHeaders headers = new HttpHeaders(); + headers.addAll("header", List.of("123", "456", "789")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(headers).hasValue("header", 456)) + .withMessageContainingAll("check primary long value for HTTP header 'header'", "123", "456"); + } + + @Test + void hasValueWithNoLongMatch() { + HttpHeaders headers = new HttpHeaders(); + headers.addAll("header", List.of("123", "456", "789")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(headers).hasValue("wrong-name", 456)) + .withMessageContainingAll("HTTP headers", "header", "wrong-name"); + } + + @Test + void hasValueWithInstantMatch() { + Instant instant = Instant.now(); + HttpHeaders headers = new HttpHeaders(); + headers.setInstant("header", instant); + assertThat(headers).hasValue("header", instant); + } + + @Test + void hasValueWithNoInstantMatch() { + Instant instant = Instant.now(); + HttpHeaders headers = new HttpHeaders(); + headers.setInstant("header", instant); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(headers).hasValue("wrong-name", instant.minusSeconds(30))) + .withMessageContainingAll("HTTP headers", "header", "wrong-name"); + } + + @Test + void hasValueWithNoInstantMatchOneSecOfDifference() { + Instant instant = Instant.now(); + HttpHeaders headers = new HttpHeaders(); + headers.setInstant("header", instant); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(headers).hasValue("wrong-name", instant.minusSeconds(1))) + .withMessageContainingAll("HTTP headers", "header", "wrong-name"); + } + + + private static HttpHeadersAssert assertThat(Map values) { + MultiValueMap map = new LinkedMultiValueMap<>(); + values.forEach(map::add); + return assertThat(new HttpHeaders(map)); + } + + private static HttpHeadersAssert assertThat(HttpHeaders values) { + return new HttpHeadersAssert(values); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/http/HttpMessageContentConverterTests.java b/spring-test/src/test/java/org/springframework/test/http/HttpMessageContentConverterTests.java new file mode 100644 index 000000000000..994f34915885 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/http/HttpMessageContentConverterTests.java @@ -0,0 +1,273 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.SmartHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.mock.http.MockHttpInputMessage; +import org.springframework.mock.http.MockHttpOutputMessage; +import org.springframework.util.StreamUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Tests for {@link HttpMessageContentConverter}. + * + * @author Stephane Nicoll + */ +class HttpMessageContentConverterTests { + + private static final MediaType JSON = MediaType.APPLICATION_JSON; + + private static final ResolvableType listOfIntegers = ResolvableType.forClassWithGenerics(List.class, Integer.class); + + private static final MappingJackson2HttpMessageConverter jacksonMessageConverter = + new MappingJackson2HttpMessageConverter(new ObjectMapper()); + + @Test + void createInstanceWithEmptyIterable() { + assertThatIllegalArgumentException() + .isThrownBy(() -> HttpMessageContentConverter.of(List.of())) + .withMessage("At least one message converter needs to be specified"); + } + + @Test + void createInstanceWithEmptyVarArg() { + assertThatIllegalArgumentException() + .isThrownBy(HttpMessageContentConverter::of) + .withMessage("At least one message converter needs to be specified"); + } + + @Test + void convertInvokesFirstMatchingConverter() throws IOException { + HttpInputMessage message = createMessage("1,2,3"); + SmartHttpMessageConverter firstConverter = mockSmartConverterForRead( + listOfIntegers, JSON, message, List.of(1, 2, 3)); + SmartHttpMessageConverter secondConverter = mockSmartConverterForRead( + listOfIntegers, JSON, message, List.of(3, 2, 1)); + HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of( + List.of(firstConverter, secondConverter)); + List data = contentConverter.convert(message, JSON, listOfIntegers); + assertThat(data).containsExactly(1, 2, 3); + verify(firstConverter).canRead(listOfIntegers, JSON); + verifyNoInteractions(secondConverter); + } + + @Test + void convertInvokesGenericHttpMessageConverter() throws IOException { + GenericHttpMessageConverter firstConverter = mock(GenericHttpMessageConverter.class); + HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of( + List.of(firstConverter, jacksonMessageConverter)); + List data = contentConverter.convert(createMessage("[2,3,4]"), JSON, listOfIntegers); + assertThat(data).containsExactly(2, 3, 4); + verify(firstConverter).canRead(listOfIntegers.getType(), List.class, JSON); + } + + @Test + void convertInvokesSmartHttpMessageConverter() throws IOException { + HttpInputMessage message = createMessage("dummy"); + GenericHttpMessageConverter firstConverter = mock(GenericHttpMessageConverter.class); + SmartHttpMessageConverter smartConverter = mockSmartConverterForRead( + listOfIntegers, JSON, message, List.of(1, 2, 3)); + HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of( + List.of(firstConverter, smartConverter)); + List data = contentConverter.convert(message, JSON, listOfIntegers); + assertThat(data).containsExactly(1, 2, 3); + verify(smartConverter).canRead(listOfIntegers, JSON); + } + + @Test + void convertInvokesHttpMessageConverter() throws IOException { + HttpInputMessage message = createMessage("1,2,3"); + SmartHttpMessageConverter secondConverter = mockSmartConverterForRead( + listOfIntegers, JSON, message, List.of(1, 2, 3)); + HttpMessageConverter thirdConverter = mockSimpleConverterForRead( + List.class, MediaType.TEXT_PLAIN, message, List.of(1, 2, 3)); + HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of( + List.of(jacksonMessageConverter, secondConverter, thirdConverter)); + List data = contentConverter.convert(message, MediaType.TEXT_PLAIN, listOfIntegers); + assertThat(data).containsExactly(1, 2, 3); + verify(secondConverter).canRead(listOfIntegers, MediaType.TEXT_PLAIN); + verify(thirdConverter).canRead(List.class, MediaType.TEXT_PLAIN); + } + + @Test + void convertFailsIfNoMatchingConverterIsFound() throws IOException { + HttpInputMessage message = createMessage("[1,2,3]"); + SmartHttpMessageConverter textConverter = mockSmartConverterForRead( + listOfIntegers, MediaType.TEXT_PLAIN, message, List.of(1, 2, 3)); + SmartHttpMessageConverter htmlConverter = mockSmartConverterForRead( + listOfIntegers, MediaType.TEXT_HTML, message, List.of(3, 2, 1)); + HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of( + List.of(textConverter, htmlConverter)); + assertThatIllegalStateException() + .isThrownBy(() -> contentConverter.convert(message, JSON, listOfIntegers)) + .withMessage("No converter found to read [application/json] to [java.util.List]"); + verify(textConverter).canRead(listOfIntegers, JSON); + verify(htmlConverter).canRead(listOfIntegers, JSON); + } + + @Test + void convertViaJsonInvokesFirstMatchingConverter() throws IOException { + String value = "1,2,3"; + ResolvableType valueType = ResolvableType.forInstance(value); + SmartHttpMessageConverter readConverter = mockSmartConverterForRead(listOfIntegers, JSON, null, List.of(1, 2, 3)); + SmartHttpMessageConverter firstWriteJsonConverter = mockSmartConverterForWritingJson(value, valueType, "[1,2,3]"); + SmartHttpMessageConverter secondWriteJsonConverter = mockSmartConverterForWritingJson(value, valueType, "[3,2,1]"); + HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of( + List.of(readConverter, firstWriteJsonConverter, secondWriteJsonConverter)); + List data = contentConverter.convertViaJson(value, listOfIntegers); + assertThat(data).containsExactly(1, 2, 3); + verify(readConverter).canRead(listOfIntegers, JSON); + verify(firstWriteJsonConverter).canWrite(valueType, String.class, JSON); + verifyNoInteractions(secondWriteJsonConverter); + } + + @Test + void convertViaJsonInvokesGenericHttpMessageConverter() throws IOException { + String value = "1,2,3"; + ResolvableType valueType = ResolvableType.forInstance(value); + SmartHttpMessageConverter readConverter = mockSmartConverterForRead(listOfIntegers, JSON, null, List.of(1, 2, 3)); + GenericHttpMessageConverter writeConverter = mockGenericConverterForWritingJson(value, valueType, "[3,2,1]"); + HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of( + List.of(readConverter, writeConverter, jacksonMessageConverter)); + List data = contentConverter.convertViaJson("[1, 2, 3]", listOfIntegers); + assertThat(data).containsExactly(1, 2, 3); + verify(readConverter).canRead(listOfIntegers, JSON); + verify(writeConverter).canWrite(valueType.getType(), value.getClass(), JSON); + } + + @Test + void convertViaJsonInvokesSmartHttpMessageConverter() throws IOException { + String value = "1,2,3"; + ResolvableType valueType = ResolvableType.forInstance(value); + SmartHttpMessageConverter readConverter = mockSmartConverterForRead(listOfIntegers, JSON, null, List.of(1, 2, 3)); + SmartHttpMessageConverter writeConverter = mockSmartConverterForWritingJson(value, valueType, "[3,2,1]"); + HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of( + List.of(readConverter, writeConverter, jacksonMessageConverter)); + List data = contentConverter.convertViaJson("[1, 2, 3]", listOfIntegers); + assertThat(data).containsExactly(1, 2, 3); + verify(readConverter).canRead(listOfIntegers, JSON); + verify(writeConverter).canWrite(valueType, value.getClass(), JSON); + } + + @Test + void convertViaJsonInvokesHttpMessageConverter() throws IOException { + String value = "1,2,3"; + SmartHttpMessageConverter readConverter = mockSmartConverterForRead(listOfIntegers, JSON, null, List.of(1, 2, 3)); + HttpMessageConverter writeConverter = mockSimpleConverterForWritingJson(value, "[3,2,1]"); + HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of( + List.of(readConverter, writeConverter, jacksonMessageConverter)); + List data = contentConverter.convertViaJson("[1, 2, 3]", listOfIntegers); + assertThat(data).containsExactly(1, 2, 3); + verify(readConverter).canRead(listOfIntegers, JSON); + verify(writeConverter).canWrite(value.getClass(), JSON); + } + + @Test + void convertViaJsonFailsIfNoMatchingConverterIsFound() throws IOException { + String value = "1,2,3"; + ResolvableType valueType = ResolvableType.forInstance(value); + SmartHttpMessageConverter readConverter = mockSmartConverterForRead(listOfIntegers, JSON, null, List.of(1, 2, 3)); + HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of(List.of(readConverter)); + assertThatIllegalStateException() + .isThrownBy(() -> contentConverter.convertViaJson(value, listOfIntegers)) + .withMessage("No converter found to convert [java.lang.String] to JSON"); + verify(readConverter).canWrite(valueType, value.getClass(), JSON); + } + + @SuppressWarnings("unchecked") + private static SmartHttpMessageConverter mockSmartConverterForRead( + ResolvableType type, MediaType mediaType, @Nullable HttpInputMessage message, Object value) throws IOException { + SmartHttpMessageConverter converter = mock(SmartHttpMessageConverter.class); + given(converter.canRead(type, mediaType)).willReturn(true); + given(converter.read(eq(type), (message != null ? eq(message) : any()), any())).willReturn(value); + return converter; + } + + @SuppressWarnings("unchecked") + private static SmartHttpMessageConverter mockSmartConverterForWritingJson(Object value, ResolvableType valueType, String json) throws IOException { + SmartHttpMessageConverter converter = mock(SmartHttpMessageConverter.class); + given(converter.canWrite(valueType, value.getClass(), JSON)).willReturn(true); + willAnswer(invocation -> { + MockHttpOutputMessage out = invocation.getArgument(3, MockHttpOutputMessage.class); + StreamUtils.copy(json, StandardCharsets.UTF_8, out.getBody()); + return null; + }).given(converter).write(eq(value), eq(valueType), eq(JSON), any(), any()); + return converter; + } + + @SuppressWarnings("unchecked") + private static GenericHttpMessageConverter mockGenericConverterForWritingJson(Object value, ResolvableType valueType, String json) throws IOException { + GenericHttpMessageConverter converter = mock(GenericHttpMessageConverter.class); + given(converter.canWrite(valueType.getType(), value.getClass(), JSON)).willReturn(true); + willAnswer(invocation -> { + MockHttpOutputMessage out = invocation.getArgument(4, MockHttpOutputMessage.class); + StreamUtils.copy(json, StandardCharsets.UTF_8, out.getBody()); + return null; + }).given(converter).write(eq(value), eq(valueType.getType()), eq(JSON), any()); + return converter; + } + + @SuppressWarnings("unchecked") + private static HttpMessageConverter mockSimpleConverterForRead( + Class rawType, MediaType mediaType, HttpInputMessage message, Object value) throws IOException { + HttpMessageConverter converter = mock(HttpMessageConverter.class); + given(converter.canRead(rawType, mediaType)).willReturn(true); + given(converter.read(rawType, message)).willReturn(value); + return converter; + } + + @SuppressWarnings("unchecked") + private static HttpMessageConverter mockSimpleConverterForWritingJson(Object value, String json) throws IOException { + HttpMessageConverter converter = mock(HttpMessageConverter.class); + given(converter.canWrite(value.getClass(), JSON)).willReturn(true); + willAnswer(invocation -> { + MockHttpOutputMessage out = invocation.getArgument(2, MockHttpOutputMessage.class); + StreamUtils.copy(json, StandardCharsets.UTF_8, out.getBody()); + return null; + }).given(converter).write(eq(value), eq(JSON), any()); + return converter; + } + + private static HttpInputMessage createMessage(String content) { + return new MockHttpInputMessage(content.getBytes(StandardCharsets.UTF_8)); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/http/MediaTypeAssertTests.java b/spring-test/src/test/java/org/springframework/test/http/MediaTypeAssertTests.java new file mode 100644 index 000000000000..3e0536dfdd55 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/http/MediaTypeAssertTests.java @@ -0,0 +1,213 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link MediaTypeAssert}. + * + * @author Brian Clozel + * @author Stephane Nicoll + */ +class MediaTypeAssertTests { + + @Test + void actualCanBeNull() { + new MediaTypeAssert((MediaType) null).isNull(); + } + + @Test + void actualStringCanBeNull() { + new MediaTypeAssert((String) null).isNull(); + } + + @Test + void isEqualWhenActualIsNullStringShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(null).isEqualTo("text/html")) + .withMessageContaining("Media type"); + } + + @Test + void isEqualWhenSameStringShouldPass() { + assertThat(mediaType("application/json")).isEqualTo("application/json"); + } + + @Test + void isEqualWhenDifferentStringShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isEqualTo("text/html")) + .withMessageContaining("Media type"); + } + + @Test + void isEqualInvalidStringShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isEqualTo("example of a bad value")) + .withMessageContainingAll("[Media type]", "To be a valid media type but got:", + "\"Invalid mime type \"example of a bad value\": does not contain '/'\""); + } + + @Test + void isEqualWhenActualIsNullTypeShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(null).isEqualTo(MediaType.APPLICATION_JSON)) + .withMessageContaining("Media type"); + } + + @Test + void isEqualWhenSameTypeShouldPass() { + assertThat(mediaType("application/json")).isEqualTo(MediaType.APPLICATION_JSON); + } + + @Test + void isEqualWhenDifferentTypeShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isEqualTo(MediaType.TEXT_HTML)) + .withMessageContaining("Media type"); + } + + @Test + void isNotEqualWhenActualIsNullStringShouldPass() { + assertThat(null).isNotEqualTo("application/json"); + } + + @Test + void isNotEqualWhenDifferentStringShouldPass() { + assertThat(mediaType("application/json")).isNotEqualTo("text/html"); + } + + @Test + void isNotEqualWhenSameStringShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isNotEqualTo("application/json")) + .withMessageContaining("Media type"); + } + + @Test + void isNotEqualInvalidStringShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isNotEqualTo("example of a bad value")) + .withMessageContainingAll("[Media type]", "To be a valid media type but got:", + "\"Invalid mime type \"example of a bad value\": does not contain '/'\""); + } + + @Test + void isNotEqualWhenActualIsNullTypeShouldPass() { + assertThat(null).isNotEqualTo(MediaType.APPLICATION_JSON); + } + + @Test + void isNotEqualWhenDifferentTypeShouldPass() { + assertThat(mediaType("application/json")).isNotEqualTo(MediaType.TEXT_HTML); + } + + @Test + void isNotEqualWhenSameTypeShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isNotEqualTo(MediaType.APPLICATION_JSON)) + .withMessageContaining("Media type"); + } + + @Test + void isCompatibleWhenSameShouldPass() { + assertThat(mediaType("application/json")).isCompatibleWith("application/json"); + } + + @Test + void isCompatibleWhenCompatibleShouldPass() { + assertThat(mediaType("application/json")).isCompatibleWith("application/*"); + } + + @Test + void isCompatibleWhenDifferentShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isCompatibleWith("text/html")) + .withMessageContaining("check media type 'application/json' is compatible with 'text/html'"); + } + + @Test + void isCompatibleWithStringAndNullActual() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(null).isCompatibleWith("text/html")) + .withMessageContaining("Expecting null to be compatible with 'text/html'"); + } + + @Test + void isCompatibleWithStringAndNullExpected() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isCompatibleWith((String) null)) + .withMessageContainingAll("Expecting:", "null", "To be a valid media type but got:", + "'mimeType' must not be empty"); + } + + @Test + void isCompatibleWithStringAndEmptyExpected() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isCompatibleWith("")) + .withMessageContainingAll("Expecting:", "To be a valid media type but got:", + "'mimeType' must not be empty"); + } + + @Test + void isCompatibleWithMediaTypeAndNullActual() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(null).isCompatibleWith(MediaType.TEXT_HTML)) + .withMessageContaining("Expecting null to be compatible with 'text/html'"); + } + + @Test + void isCompatibleWithMediaTypeAndNullExpected() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isCompatibleWith((MediaType) null)) + .withMessageContaining("Expecting 'application/json' to be compatible with null"); + } + + @Test + void isCompatibleWhenSameTypeShouldPass() { + assertThat(mediaType("application/json")).isCompatibleWith(MediaType.APPLICATION_JSON); + } + + @Test + void isCompatibleWhenCompatibleTypeShouldPass() { + assertThat(mediaType("application/json")).isCompatibleWith(MediaType.parseMediaType("application/*")); + } + + @Test + void isCompatibleWhenDifferentTypeShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mediaType("application/json")).isCompatibleWith(MediaType.TEXT_HTML)) + .withMessageContaining("check media type 'application/json' is compatible with 'text/html'"); + } + + + @Nullable + private static MediaType mediaType(@Nullable String mediaType) { + return (mediaType != null ? MediaType.parseMediaType(mediaType) : null); + } + + private static MediaTypeAssert assertThat(@Nullable MediaType mediaType) { + return new MediaTypeAssert(mediaType); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java b/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java new file mode 100644 index 000000000000..1c276b2463a2 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java @@ -0,0 +1,903 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.json; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.AssertProvider; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.assertj.core.api.InstanceOfAssertFactory; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.skyscreamer.jsonassert.JSONCompareMode; +import org.skyscreamer.jsonassert.JSONCompareResult; +import org.skyscreamer.jsonassert.comparator.JSONComparator; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.test.http.HttpMessageContentConverter; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link AbstractJsonContentAssert}. + * + * @author Stephane Nicoll + * @author Phillip Webb + */ +class AbstractJsonContentAssertTests { + + private static final String TYPES = loadJson("types.json"); + + private static final String SIMPSONS = loadJson("simpsons.json"); + + private static final String NULLS = loadJson("nulls.json"); + + private static final String SOURCE = loadJson("source.json"); + + private static final String LENIENT_SAME = loadJson("lenient-same.json"); + + private static final String DIFFERENT = loadJson("different.json"); + + private static final HttpMessageContentConverter jsonContentConverter = HttpMessageContentConverter.of( + new MappingJackson2HttpMessageConverter(new ObjectMapper())); + + private static final JsonComparator comparator = JsonAssert.comparator(JsonCompareMode.LENIENT); + + @Test + void isNullWhenActualIsNullShouldPass() { + assertThat(forJson(null)).isNull(); + } + + @Test + void satisfiesAllowFurtherAssertions() { + assertThat(forJson(SIMPSONS)).satisfies(content -> { + assertThat(content).extractingPath("$.familyMembers[0].name").isEqualTo("Homer"); + assertThat(content).extractingPath("$.familyMembers[1].name").isEqualTo("Marge"); + }); + } + + @Nested + class ConversionTests { + + @Test + void convertToTargetType() { + assertThat(forJson(SIMPSONS, jsonContentConverter)) + .convertTo(Family.class) + .satisfies(family -> assertThat(family.familyMembers()).hasSize(5)); + } + + @Test + void convertToIncompatibleTargetTypeShouldFail() { + AbstractJsonContentAssert jsonAssert = assertThat(forJson(SIMPSONS, jsonContentConverter)); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> jsonAssert.convertTo(Member.class)) + .withMessageContainingAll("To convert successfully to:", + Member.class.getName(), "But it failed:"); + } + + @Test + void convertUsingAssertFactory() { + assertThat(forJson(SIMPSONS, jsonContentConverter)) + .convertTo(new FamilyAssertFactory()) + .hasFamilyMember("Homer"); + } + + private AssertProvider> forJson(@Nullable String json, + @Nullable HttpMessageContentConverter jsonContentConverter) { + + return () -> new TestJsonContentAssert(json, jsonContentConverter); + } + + private static class FamilyAssertFactory extends InstanceOfAssertFactory { + public FamilyAssertFactory() { + super(Family.class, FamilyAssert::new); + } + } + + private static class FamilyAssert extends AbstractObjectAssert { + public FamilyAssert(Family family) { + super(family, FamilyAssert.class); + } + + public FamilyAssert hasFamilyMember(String name) { + assertThat(this.actual.familyMembers).anySatisfy(m -> assertThat(m.name()).isEqualTo(name)); + return this.myself; + } + } + } + + @Nested + class HasPathTests { + + @Test + void hasPathForNullJson() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(null)).hasPath("no")) + .withMessageContaining("Expecting actual not to be null"); + } + + @Test + void hasPathForPresentAndNotNull() { + assertThat(forJson(NULLS)).hasPath("$.valuename"); + } + + @Test + void hasPathForPresentAndNull() { + assertThat(forJson(NULLS)).hasPath("$.nullname"); + } + + @Test + void hasPathForOperatorMatching() { + assertThat(forJson(SIMPSONS)). + hasPath("$.familyMembers[?(@.name == 'Homer')]"); + } + + @Test + void hasPathForOperatorNotMatching() { + assertThat(forJson(SIMPSONS)). + hasPath("$.familyMembers[?(@.name == 'Dilbert')]"); + } + + @Test + void hasPathForNotPresent() { + String expression = "$.missing"; + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(NULLS)).hasPath(expression)) + .satisfies(hasFailedToMatchPath("$.missing")); + } + + @Test + void hasPathSatisfying() { + assertThat(forJson(TYPES)).hasPathSatisfying("$.str", value -> assertThat(value).isEqualTo("foo")) + .hasPathSatisfying("$.num", value -> assertThat(value).isEqualTo(5)); + } + + @Test + void hasPathSatisfyingForPathNotPresent() { + String expression = "missing"; + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(NULLS)).hasPathSatisfying(expression, value -> {})) + .satisfies(hasFailedToMatchPath(expression)); + } + + @Test + void doesNotHavePathForMissing() { + assertThat(forJson(NULLS)).doesNotHavePath("$.missing"); + } + + @Test + void doesNotHavePathForPresent() { + String expression = "$.valuename"; + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(NULLS)).doesNotHavePath(expression)) + .satisfies(hasFailedToNotMatchPath(expression)); + } + } + + @Nested + class ExtractingPathTests { + + @Test + void extractingPathForNullJson() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(null)).extractingPath("$")) + .withMessageContaining("Expecting actual not to be null"); + } + + @Test + void isNullWithNullPathValue() { + assertThat(forJson(NULLS)).extractingPath("$.nullname").isNull(); + } + + @ParameterizedTest + @ValueSource(strings = { "$.str", "$.emptyString", "$.num", "$.bool", "$.arr", + "$.emptyArray", "$.colorMap", "$.emptyMap" }) + void isNotNullWithValue(String path) { + assertThat(forJson(TYPES)).extractingPath(path).isNotNull(); + } + + @ParameterizedTest + @MethodSource + void isEqualToOnRawValue(String path, Object expected) { + assertThat(forJson(TYPES)).extractingPath(path).isEqualTo(expected); + } + + static Stream isEqualToOnRawValue() { + return Stream.of( + Arguments.of("$.str", "foo"), + Arguments.of("$.num", 5), + Arguments.of("$.bool", true), + Arguments.of("$.arr", List.of(42)), + Arguments.of("$.colorMap", Map.of("red", "rojo"))); + } + + @Test + void asStringWithActualValue() { + assertThat(forJson(TYPES)).extractingPath("@.str").asString().startsWith("f").endsWith("o"); + } + + @Test + void asStringIsEmpty() { + assertThat(forJson(TYPES)).extractingPath("@.emptyString").asString().isEmpty(); + } + + @Test + void asNumberWithActualValue() { + assertThat(forJson(TYPES)).extractingPath("@.num").asNumber().isEqualTo(5); + } + + @Test + void asBooleanWithActualValue() { + assertThat(forJson(TYPES)).extractingPath("@.bool").asBoolean().isTrue(); + } + + @Test + void asArrayWithActualValue() { + assertThat(forJson(TYPES)).extractingPath("@.arr").asArray().containsOnly(42); + } + + @Test + void asArrayIsEmpty() { + assertThat(forJson(TYPES)).extractingPath("@.emptyArray").asArray().isEmpty(); + } + + @Test + void asArrayWithFilterPredicatesMatching() { + assertThat(forJson(SIMPSONS)) + .extractingPath("$.familyMembers[?(@.name == 'Bart')]").asArray().hasSize(1); + } + + @Test + void asArrayWithFilterPredicatesNotMatching() { + assertThat(forJson(SIMPSONS)). + extractingPath("$.familyMembers[?(@.name == 'Dilbert')]").asArray().isEmpty(); + } + + @Test + void asMapWithActualValue() { + assertThat(forJson(TYPES)).extractingPath("@.colorMap").asMap().containsOnly(entry("red", "rojo")); + } + + @Test + void asMapIsEmpty() { + assertThat(forJson(TYPES)).extractingPath("@.emptyMap").asMap().isEmpty(); + } + + @Test + void convertToWithoutHttpMessageConverterShouldFail() { + JsonPathValueAssert path = assertThat(forJson(SIMPSONS)).extractingPath("$.familyMembers[0]"); + assertThatIllegalStateException() + .isThrownBy(() -> path.convertTo(Member.class)) + .withMessage("No JSON message converter available to convert {name=Homer}"); + } + + @Test + void convertToTargetType() { + assertThat(forJson(SIMPSONS, jsonContentConverter)) + .extractingPath("$.familyMembers[0]").convertTo(Member.class) + .satisfies(member -> assertThat(member.name).isEqualTo("Homer")); + } + + @Test + void convertToIncompatibleTargetTypeShouldFail() { + JsonPathValueAssert path = assertThat(forJson(SIMPSONS, jsonContentConverter)) + .extractingPath("$.familyMembers[0]"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> path.convertTo(ExtractingPathTests.Customer.class)) + .withMessageContainingAll("Expected value at JSON path \"$.familyMembers[0]\":", + Customer.class.getName(), "name"); + } + + @Test + void convertArrayUsingAssertFactory() { + assertThat(forJson(SIMPSONS, jsonContentConverter)) + .extractingPath("$.familyMembers") + .convertTo(InstanceOfAssertFactories.list(Member.class)) + .hasSize(5).element(0).isEqualTo(new Member("Homer")); + } + + @Test + void isEmptyWithPathHavingNullValue() { + assertThat(forJson(NULLS)).extractingPath("nullname").isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { "$.emptyString", "$.emptyArray", "$.emptyMap" }) + void isEmptyWithEmptyValue(String path) { + assertThat(forJson(TYPES)).extractingPath(path).isEmpty(); + } + + @Test + void isEmptyForPathWithFilterMatching() { + String expression = "$.familyMembers[?(@.name == 'Bart')]"; + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SIMPSONS)).extractingPath(expression).isEmpty()) + .withMessageContainingAll("Expected value at JSON path \"" + expression + "\"", + "[{\"name\":\"Bart\"}]", "To be empty"); + } + + @Test + void isEmptyForPathWithFilterNotMatching() { + assertThat(forJson(SIMPSONS)).extractingPath("$.familyMembers[?(@.name == 'Dilbert')]").isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { "$.str", "$.num", "$.bool", "$.arr", "$.colorMap" }) + void isNotEmptyWithNonNullValue(String path) { + assertThat(forJson(TYPES)).extractingPath(path).isNotEmpty(); + } + + @Test + void isNotEmptyForPathWithFilterMatching() { + assertThat(forJson(SIMPSONS)).extractingPath("$.familyMembers[?(@.name == 'Bart')]").isNotEmpty(); + } + + @Test + void isNotEmptyForPathWithFilterNotMatching() { + String expression = "$.familyMembers[?(@.name == 'Dilbert')]"; + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SIMPSONS)).extractingPath(expression).isNotEmpty()) + .withMessageContainingAll("Expected value at JSON path \"" + expression + "\"", + "To not be empty"); + } + + + private record Customer(long id, String username) {} + + private AssertProvider> forJson(@Nullable String json) { + return () -> new TestJsonContentAssert(json, null); + } + + private AssertProvider> forJson(@Nullable String json, HttpMessageContentConverter jsonContentConverter) { + return () -> new TestJsonContentAssert(json, jsonContentConverter); + } + } + + @Nested + class EqualsNotEqualsTests { + + @Test + void isEqualToWhenStringIsMatchingShouldPass() { + assertThat(forJson(SOURCE)).isEqualTo(SOURCE); + } + + @Test + void isEqualToWhenNullActualShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(forJson(null)).isEqualTo(SOURCE)); + } + + @Test + void isEqualToWhenExpectedIsNotAStringShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo(SOURCE.getBytes())); + } + } + + @Nested + @TestInstance(Lifecycle.PER_CLASS) + class JsonAssertTests { + + @Test + void isEqualToWhenExpectedIsNullShouldFail() { + CharSequence actual = null; + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo(actual, JsonCompareMode.LENIENT)); + } + + @Test + void isEqualToWhenStringIsMatchingAndLenientShouldPass() { + assertThat(forJson(SOURCE)).isEqualTo(LENIENT_SAME, JsonCompareMode.LENIENT); + } + + @Test + void isEqualToWhenStringIsNotMatchingAndLenientShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo(DIFFERENT, JsonCompareMode.LENIENT)); + } + + @Test + void isEqualToWhenResourcePathIsMatchingAndLenientShouldPass() { + assertThat(forJson(SOURCE)).isEqualTo("lenient-same.json", JsonCompareMode.LENIENT); + } + + @Test + void isEqualToWhenResourcePathIsNotMatchingAndLenientShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo("different.json", JsonCompareMode.LENIENT)); + } + + Stream source() { + return Stream.of( + Arguments.of(new ClassPathResource("source.json", AbstractJsonContentAssertTests.class)), + Arguments.of(new ByteArrayResource(SOURCE.getBytes())), + Arguments.of(new FileSystemResource(createFile(SOURCE))), + Arguments.of(new InputStreamResource(createInputStream(SOURCE)))); + } + + Stream lenientSame() { + return Stream.of( + Arguments.of(new ClassPathResource("lenient-same.json", AbstractJsonContentAssertTests.class)), + Arguments.of(new ByteArrayResource(LENIENT_SAME.getBytes())), + Arguments.of(new FileSystemResource(createFile(LENIENT_SAME))), + Arguments.of(new InputStreamResource(createInputStream(LENIENT_SAME)))); + } + + Stream different() { + return Stream.of( + Arguments.of(new ClassPathResource("different.json", AbstractJsonContentAssertTests.class)), + Arguments.of(new ByteArrayResource(DIFFERENT.getBytes())), + Arguments.of(new FileSystemResource(createFile(DIFFERENT))), + Arguments.of(new InputStreamResource(createInputStream(DIFFERENT)))); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isEqualToWhenResourceIsMatchingAndLenientSameShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isEqualTo(expected, JsonCompareMode.LENIENT); + } + + @ParameterizedTest + @MethodSource("different") + void isEqualToWhenResourceIsNotMatchingAndLenientShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(forJson(SOURCE)).isEqualTo(expected, JsonCompareMode.LENIENT)); + } + + @Test + void isEqualToWhenStringIsMatchingAndComparatorShouldPass() { + assertThat(forJson(SOURCE)).isEqualTo(LENIENT_SAME, comparator); + } + + @Test + void isEqualToWhenStringIsNotMatchingAndComparatorShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo(DIFFERENT, comparator)); + } + + @Test + void isEqualToWhenResourcePathIsMatchingAndComparatorShouldPass() { + assertThat(forJson(SOURCE)).isEqualTo("lenient-same.json", comparator); + } + + @Test + void isEqualToWhenResourcePathIsNotMatchingAndComparatorShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo("different.json", comparator)); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isEqualToWhenResourceIsMatchingAndComparatorShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isEqualTo(expected, comparator); + } + + @ParameterizedTest + @MethodSource("different") + void isEqualToWhenResourceIsNotMatchingAndComparatorShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isEqualTo(expected, comparator)); + } + + @Test + void isLenientlyEqualToWhenStringIsMatchingShouldPass() { + assertThat(forJson(SOURCE)).isLenientlyEqualTo(LENIENT_SAME); + } + + @Test + void isLenientlyEqualToWhenNullActualShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(null)).isLenientlyEqualTo(SOURCE)); + } + + @Test + void isLenientlyEqualToWhenStringIsNotMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isLenientlyEqualTo(DIFFERENT)); + } + + @Test + void isLenientlyEqualToWhenExpectedDoesNotExistShouldFail() { + assertThatIllegalStateException() + .isThrownBy(() -> assertThat(forJson(SOURCE)).isLenientlyEqualTo("does-not-exist.json")) + .withMessage("Unable to load JSON from class path resource [org/springframework/test/json/does-not-exist.json]"); + } + + @Test + void isLenientlyEqualToWhenResourcePathIsMatchingShouldPass() { + assertThat(forJson(SOURCE)).isLenientlyEqualTo("lenient-same.json"); + } + + @Test + void isLenientlyEqualToWhenResourcePathIsNotMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isLenientlyEqualTo("different.json")); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isLenientlyEqualToWhenResourceIsMatchingShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isLenientlyEqualTo(expected); + } + + @ParameterizedTest + @MethodSource("different") + void isLenientlyEqualToWhenResourceIsNotMatchingShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isLenientlyEqualTo(expected)); + } + + @Test + void isStrictlyEqualToWhenStringIsMatchingShouldPass() { + assertThat(forJson(SOURCE)).isStrictlyEqualTo(SOURCE); + } + + @Test + void isStrictlyEqualToWhenStringIsNotMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isStrictlyEqualTo(LENIENT_SAME)); + } + + @Test + void isStrictlyEqualToWhenResourcePathIsMatchingShouldPass() { + assertThat(forJson(SOURCE)).isStrictlyEqualTo("source.json"); + } + + @Test + void isStrictlyEqualToWhenResourcePathIsNotMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isStrictlyEqualTo("lenient-same.json")); + } + + @ParameterizedTest + @MethodSource("source") + void isStrictlyEqualToWhenResourceIsMatchingShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isStrictlyEqualTo(expected); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isStrictlyEqualToWhenResourceIsNotMatchingShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isStrictlyEqualTo(expected)); + } + + @Test + void isNotEqualToWhenStringIsMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotEqualTo(SOURCE)); + } + + @Test + void isNotEqualToWhenNullActualShouldPass() { + assertThat(forJson(null)).isNotEqualTo(SOURCE); + } + + @Test + void isNotEqualToWhenStringIsNotMatchingShouldPass() { + assertThat(forJson(SOURCE)).isNotEqualTo(DIFFERENT); + } + + @Test + void isNotEqualToAsObjectWhenExpectedIsNotAStringShouldNotFail() { + assertThat(forJson(SOURCE)).isNotEqualTo(SOURCE.getBytes()); + } + + @Test + void isNotEqualToWhenStringIsMatchingAndLenientShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotEqualTo(LENIENT_SAME, JsonCompareMode.LENIENT)); + } + + @Test + void isNotEqualToWhenStringIsNotMatchingAndLenientShouldPass() { + assertThat(forJson(SOURCE)).isNotEqualTo(DIFFERENT, JsonCompareMode.LENIENT); + } + + @Test + void isNotEqualToWhenResourcePathIsMatchingAndLenientShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(forJson(SOURCE)).isNotEqualTo("lenient-same.json", JsonCompareMode.LENIENT)); + } + + @Test + void isNotEqualToWhenResourcePathIsNotMatchingAndLenientShouldPass() { + assertThat(forJson(SOURCE)).isNotEqualTo("different.json", JsonCompareMode.LENIENT); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isNotEqualToWhenResourceIsMatchingAndLenientShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertThat(forJson(SOURCE)) + .isNotEqualTo(expected, JsonCompareMode.LENIENT)); + } + + @ParameterizedTest + @MethodSource("different") + void isNotEqualToWhenResourceIsNotMatchingAndLenientShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isNotEqualTo(expected, JsonCompareMode.LENIENT); + } + + @Test + void isNotEqualToWhenStringIsMatchingAndComparatorShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotEqualTo(LENIENT_SAME, comparator)); + } + + @Test + void isNotEqualToWhenStringIsNotMatchingAndComparatorShouldPass() { + assertThat(forJson(SOURCE)).isNotEqualTo(DIFFERENT, comparator); + } + + @Test + void isNotEqualToWhenResourcePathIsMatchingAndComparatorShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotEqualTo("lenient-same.json", comparator)); + } + + @Test + void isNotEqualToWhenResourcePathIsNotMatchingAndComparatorShouldPass() { + assertThat(forJson(SOURCE)).isNotEqualTo("different.json", comparator); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isNotEqualToWhenResourceIsMatchingAndComparatorShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(forJson(SOURCE)).isNotEqualTo(expected, comparator)); + } + + @ParameterizedTest + @MethodSource("different") + void isNotEqualToWhenResourceIsNotMatchingAndComparatorShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isNotEqualTo(expected, comparator); + } + + @Test + void isNotEqualToWhenResourceIsMatchingAndComparatorShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotEqualTo(createResource(LENIENT_SAME), comparator)); + } + + @Test + void isNotEqualToWhenResourceIsNotMatchingAndComparatorShouldPass() { + assertThat(forJson(SOURCE)).isNotEqualTo(createResource(DIFFERENT), comparator); + } + + @Test + void isNotLenientlyEqualToWhenNullActualShouldPass() { + assertThat(forJson(null)).isNotLenientlyEqualTo(SOURCE); + } + + @Test + void isNotLenientlyEqualToWhenStringIsNotMatchingShouldPass() { + assertThat(forJson(SOURCE)).isNotLenientlyEqualTo(DIFFERENT); + } + + @Test + void isNotLenientlyEqualToWhenResourcePathIsMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotLenientlyEqualTo("lenient-same.json")); + } + + @Test + void isNotLenientlyEqualToWhenResourcePathIsNotMatchingShouldPass() { + assertThat(forJson(SOURCE)).isNotLenientlyEqualTo("different.json"); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isNotLenientlyEqualToWhenResourceIsMatchingShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotLenientlyEqualTo(expected)); + } + + @ParameterizedTest + @MethodSource("different") + void isNotLenientlyEqualToWhenResourceIsNotMatchingShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isNotLenientlyEqualTo(expected); + } + + @Test + void isNotStrictlyEqualToWhenStringIsMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotStrictlyEqualTo(SOURCE)); + } + + @Test + void isNotStrictlyEqualToWhenStringIsNotMatchingShouldPass() { + assertThat(forJson(SOURCE)).isNotStrictlyEqualTo(LENIENT_SAME); + } + + @Test + void isNotStrictlyEqualToWhenResourcePathIsMatchingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotStrictlyEqualTo("source.json")); + } + + @Test + void isNotStrictlyEqualToWhenResourcePathIsNotMatchingShouldPass() { + assertThat(forJson(SOURCE)).isNotStrictlyEqualTo("lenient-same.json"); + } + + @ParameterizedTest + @MethodSource("source") + void isNotStrictlyEqualToWhenResourceIsMatchingShouldFail(Resource expected) { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson(SOURCE)).isNotStrictlyEqualTo(expected)); + } + + @ParameterizedTest + @MethodSource("lenientSame") + void isNotStrictlyEqualToWhenResourceIsNotMatchingShouldPass(Resource expected) { + assertThat(forJson(SOURCE)).isNotStrictlyEqualTo(expected); + } + + @Test + void isEqualToWithCustomCompareMode() { + String differentOrder = """ + { + "spring": [ + "framework", + "boot" + ] + } + """; + assertThat(forJson(SOURCE)).isEqualTo(differentOrder, JsonAssert.comparator(JSONCompareMode.NON_EXTENSIBLE)); + } + + @Test + void isEqualToWithCustomJsonComparator() throws JSONException { + String empty = "{}"; + JSONComparator comparator = mock(JSONComparator.class); + given(comparator.compareJSON(any(JSONObject.class), any(JSONObject.class))).willReturn(new JSONCompareResult()); + assertThat(forJson(SOURCE)).isEqualTo(empty, JsonAssert.comparator(comparator)); + verify(comparator).compareJSON(any(JSONObject.class), any(JSONObject.class)); + } + + @Test + void withResourceLoadClassShouldAllowToLoadRelativeContent() { + AbstractJsonContentAssert jsonAssert = assertThat(forJson(NULLS)).withResourceLoadClass(String.class); + assertThatIllegalStateException() + .isThrownBy(() -> jsonAssert.isLenientlyEqualTo("nulls.json")) + .withMessage("Unable to load JSON from class path resource [java/lang/nulls.json]"); + + assertThat(forJson(NULLS)).withResourceLoadClass(JsonContent.class).isLenientlyEqualTo("nulls.json"); + } + + private AssertProvider> forJson(@Nullable String json) { + return () -> new TestJsonContentAssert(json, null).withResourceLoadClass(getClass()); + } + } + + @Nested + class JsonComparatorTests { + + private final JsonComparator comparator = mock(JsonComparator.class); + + @Test + void isEqualToInvokesComparator() { + given(comparator.compare("{ }", "{}")).willReturn(JsonComparison.match()); + assertThat(forJson("{}")).isEqualTo("{ }", this.comparator); + verify(comparator).compare("{ }", "{}"); + } + + @Test + void isEqualToWithNoMatchProvidesErrorMessage() { + given(comparator.compare("{ }", "{}")).willReturn(JsonComparison.mismatch("No additional whitespace expected")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forJson("{}")).isEqualTo("{ }", this.comparator)) + .withMessageContaining("No additional whitespace expected"); + verify(comparator).compare("{ }", "{}"); + } + + private AssertProvider> forJson(@Nullable String json) { + return () -> new TestJsonContentAssert(json, null).withResourceLoadClass(getClass()); + } + } + + private Consumer hasFailedToMatchPath(String expression) { + return error -> assertThat(error.getMessage()).containsSubsequence("Expecting:", + "To match JSON path:", "\"" + expression + "\""); + } + + private Consumer hasFailedToNotMatchPath(String expression) { + return error -> assertThat(error.getMessage()).containsSubsequence("Expecting:", + "Not to match JSON path:", "\"" + expression + "\""); + } + + private Path createFile(String content) { + try { + Path temp = Files.createTempFile("file", ".json"); + Files.writeString(temp, content); + return temp; + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private InputStream createInputStream(String content) { + return new ByteArrayInputStream(content.getBytes()); + } + + private Resource createResource(String content) { + return new ByteArrayResource(content.getBytes()); + } + + private static String loadJson(String path) { + try { + ClassPathResource resource = new ClassPathResource(path, AbstractJsonContentAssertTests.class); + return new String(FileCopyUtils.copyToByteArray(resource.getInputStream())); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + + } + + private AssertProvider> forJson(@Nullable String json) { + return () -> new TestJsonContentAssert(json, null); + } + + + record Member(String name) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + record Family(List familyMembers) {} + + private static class TestJsonContentAssert extends AbstractJsonContentAssert { + + public TestJsonContentAssert(@Nullable String json, @Nullable HttpMessageContentConverter jsonContentConverter) { + super((json != null ? new JsonContent(json, jsonContentConverter) : null), TestJsonContentAssert.class); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/json/JsonContentTests.java b/spring-test/src/test/java/org/springframework/test/json/JsonContentTests.java new file mode 100644 index 000000000000..85a1c588a976 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/json/JsonContentTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.json; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.test.http.HttpMessageContentConverter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link JsonContent}. + * + * @author Phillip Webb + * @author Stephane Nicoll + */ +class JsonContentTests { + + private static final String JSON = "{\"name\":\"spring\", \"age\":100}"; + + @Test + void createWhenJsonIsNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy( + () -> new JsonContent(null)) + .withMessageContaining("JSON must not be null"); + } + + @Test + void assertThatShouldReturnJsonContentAssert() { + JsonContent content = new JsonContent(JSON); + assertThat(content.assertThat()).isInstanceOf(JsonContentAssert.class); + } + + @Test + void getJsonShouldReturnJson() { + JsonContent content = new JsonContent(JSON); + assertThat(content.getJson()).isEqualTo(JSON); + } + + @Test + void toStringShouldReturnString() { + JsonContent content = new JsonContent(JSON); + assertThat(content.toString()).isEqualTo("JsonContent " + JSON); + } + + @Test + void getJsonContentConverterShouldReturnConverter() { + HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of(mock(HttpMessageConverter.class)); + JsonContent content = new JsonContent(JSON, contentConverter); + assertThat(content.getContentConverter()).isSameAs(contentConverter); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java b/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java new file mode 100644 index 000000000000..60a30b779970 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java @@ -0,0 +1,332 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.json; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.assertj.core.api.AssertProvider; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.assertj.core.data.Offset; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.test.http.HttpMessageContentConverter; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link JsonPathValueAssert}. + * + * @author Stephane Nicoll + */ +class JsonPathValueAssertTests { + + @Nested + class AsStringTests { + + @Test + void asStringWithStringValue() { + assertThat(forValue("test")).asString().isEqualTo("test"); + } + + @Test + void asStringWithEmptyValue() { + assertThat(forValue("")).asString().isEmpty(); + } + + @Test + void asStringWithNonStringFails() { + int value = 123; + AssertProvider actual = forValue(value); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).asString().isEqualTo("123")) + .satisfies(hasFailedToBeOfType(value, "a string")); + } + + @Test + void asStringWithNullFails() { + AssertProvider actual = forValue(null); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).asString().isEqualTo("null")) + .satisfies(hasFailedToBeOfTypeWhenNull("a string")); + } + } + + @Nested + class AsNumberTests { + + @Test + void asNumberWithIntegerValue() { + assertThat(forValue(123)).asNumber().isEqualTo(123); + } + + @Test + void asNumberWithDoubleValue() { + assertThat(forValue(3.1415926)).asNumber() + .asInstanceOf(InstanceOfAssertFactories.DOUBLE) + .isEqualTo(3.14, Offset.offset(0.01)); + } + + @Test + void asNumberWithNonNumberFails() { + String value = "123"; + AssertProvider actual = forValue(value); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).asNumber().isEqualTo(123)) + .satisfies(hasFailedToBeOfType(value, "a number")); + } + + @Test + void asNumberWithNullFails() { + AssertProvider actual = forValue(null); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).asNumber().isEqualTo(0)) + .satisfies(hasFailedToBeOfTypeWhenNull("a number")); + } + } + + @Nested + class AsBooleanTests { + + @Test + void asBooleanWithBooleanPrimitiveValue() { + assertThat(forValue(true)).asBoolean().isEqualTo(true); + } + + @Test + void asBooleanWithBooleanWrapperValue() { + assertThat(forValue(Boolean.FALSE)).asBoolean().isEqualTo(false); + } + + @Test + void asBooleanWithNonBooleanFails() { + String value = "false"; + AssertProvider actual = forValue(value); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).asBoolean().isEqualTo(false)) + .satisfies(hasFailedToBeOfType(value, "a boolean")); + } + + @Test + void asBooleanWithNullFails() { + AssertProvider actual = forValue(null); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).asBoolean().isEqualTo(false)) + .satisfies(hasFailedToBeOfTypeWhenNull("a boolean")); + } + } + + @Nested + class AsArrayTests { // json path uses List for arrays + + @Test + void asArrayWithStringValues() { + assertThat(forValue(List.of("a", "b", "c"))).asArray().contains("a", "c"); + } + + @Test + void asArrayWithEmptyArray() { + assertThat(forValue(Collections.emptyList())).asArray().isEmpty(); + } + + @Test + void asArrayWithNonArrayFails() { + String value = "test"; + AssertProvider actual = forValue(value); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).asArray().contains("t")) + .satisfies(hasFailedToBeOfType(value, "an array")); + } + + @Test + void asArrayWithNullFails() { + AssertProvider actual = forValue(null); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).asArray().isEqualTo(false)) + .satisfies(hasFailedToBeOfTypeWhenNull("an array")); + } + } + + @Nested + class AsMapTests { + + @Test + void asMapWithMapValue() { + assertThat(forValue(Map.of("zero", 0, "one", 1))).asMap().containsKeys("zero", "one") + .containsValues(0, 1); + } + + @Test + void asArrayWithEmptyMap() { + assertThat(forValue(Collections.emptyMap())).asMap().isEmpty(); + } + + @Test + void asMapWithNonMapFails() { + List value = List.of("a", "b"); + AssertProvider actual = forValue(value); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).asMap().containsKey("a")) + .satisfies(hasFailedToBeOfType(value, "a map")); + } + + @Test + void asMapWithNullFails() { + AssertProvider actual = forValue(null); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).asMap().isEmpty()) + .satisfies(hasFailedToBeOfTypeWhenNull("a map")); + } + } + + @Nested + class ConvertToTests { + + private static final HttpMessageContentConverter jsonContentConverter = HttpMessageContentConverter.of( + new MappingJackson2HttpMessageConverter(new ObjectMapper())); + + @Test + void convertToWithoutHttpMessageConverter() { + AssertProvider actual = () -> new JsonPathValueAssert("123", "$.test", null); + assertThatIllegalStateException().isThrownBy(() -> assertThat(actual).convertTo(Integer.class)) + .withMessage("No JSON message converter available to convert '123'"); + } + + @Test + void convertObjectToPojo() { + assertThat(forValue(Map.of("id", 1234, "name", "John", "active", true))).convertTo(User.class) + .satisfies(user -> { + assertThat(user.id).isEqualTo(1234); + assertThat(user.name).isEqualTo("John"); + assertThat(user.active).isTrue(); + }); + } + + @Test + void convertArrayToListOfPojo() { + Map user1 = Map.of("id", 1234, "name", "John", "active", true); + Map user2 = Map.of("id", 5678, "name", "Sarah", "active", false); + Map user3 = Map.of("id", 9012, "name", "Sophia", "active", true); + assertThat(forValue(List.of(user1, user2, user3))) + .convertTo(InstanceOfAssertFactories.list(User.class)) + .hasSize(3).extracting("name").containsExactly("John", "Sarah", "Sophia"); + } + + @Test + void convertObjectToPojoWithMissingMandatoryField() { + Map value = Map.of("firstName", "John"); + AssertProvider actual = forValue(value); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).convertTo(User.class)) + .satisfies(hasFailedToConvertToType(value, User.class)) + .withMessageContaining("firstName"); + } + + + private AssertProvider forValue(@Nullable Object actual) { + return () -> new JsonPathValueAssert(actual, "$.test", jsonContentConverter); + } + + + private record User(long id, String name, boolean active) {} + + } + + @Nested + class EmptyNotEmptyTests { + + @Test + void isEmptyWithEmptyString() { + assertThat(forValue("")).isEmpty(); + } + + @Test + void isEmptyWithNull() { + assertThat(forValue(null)).isEmpty(); + } + + @Test + void isEmptyWithEmptyArray() { + assertThat(forValue(Collections.emptyList())).isEmpty(); + } + + @Test + void isEmptyWithEmptyObject() { + assertThat(forValue(Collections.emptyMap())).isEmpty(); + } + + @Test + void isEmptyWithWhitespace() { + AssertProvider actual = forValue(" "); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).isEmpty()) + .satisfies(hasFailedEmptyCheck(" ")); + } + + @Test + void isNotEmptyWithString() { + assertThat(forValue("test")).isNotEmpty(); + } + + @Test + void isNotEmptyWithArray() { + assertThat(forValue(List.of("test"))).isNotEmpty(); + } + + @Test + void isNotEmptyWithObject() { + assertThat(forValue(Map.of("test", "value"))).isNotEmpty(); + } + + private Consumer hasFailedEmptyCheck(Object actual) { + return error -> assertThat(error.getMessage()).containsSubsequence("Expected value at JSON path \"$.test\":", + "" + StringUtils.quoteIfString(actual), "To be empty"); + } + } + + + private Consumer hasFailedToBeOfType(Object actual, String expectedDescription) { + return error -> assertThat(error.getMessage()).containsSubsequence("Expected value at JSON path \"$.test\":", + "" + StringUtils.quoteIfString(actual), "To be " + expectedDescription, "But was:", actual.getClass().getName()); + } + + private Consumer hasFailedToBeOfTypeWhenNull(String expectedDescription) { + return error -> assertThat(error.getMessage()).containsSubsequence("Expected value at JSON path \"$.test\":", "null", + "To be " + expectedDescription); + } + + private Consumer hasFailedToConvertToType(Object actual, Class targetType) { + return error -> assertThat(error.getMessage()).containsSubsequence("Expected value at JSON path \"$.test\":", + "" + StringUtils.quoteIfString(actual), "To convert successfully to:", targetType.getTypeName(), "But it failed:"); + } + + + + private AssertProvider forValue(@Nullable Object actual) { + return () -> new JsonPathValueAssert(actual, "$.test", null); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/mockito/MockitoAssertions.java b/spring-test/src/test/java/org/springframework/test/mockito/MockitoAssertions.java new file mode 100644 index 000000000000..c4bbfb814842 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/mockito/MockitoAssertions.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.mockito; + +import org.mockito.mock.MockName; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mockingDetails; + +/** + * Assertions for Mockito mocks and spies. + * + * @author Sam Brannen + * @since 6.2.1 + */ +public abstract class MockitoAssertions { + + public static void assertIsMock(Object obj) { + assertThat(isMock(obj)).as("is a Mockito mock").isTrue(); + assertIsNotSpy(obj); + } + + public static void assertIsMock(Object obj, String message) { + assertThat(isMock(obj)).as("%s is a Mockito mock", message).isTrue(); + assertIsNotSpy(obj, message); + } + + public static void assertIsNotMock(Object obj) { + assertThat(isMock(obj)).as("is a Mockito mock").isFalse(); + } + + public static void assertIsNotMock(Object obj, String message) { + assertThat(isMock(obj)).as("%s is a Mockito mock", message).isFalse(); + } + + public static void assertIsSpy(Object obj) { + assertThat(isSpy(obj)).as("is a Mockito spy").isTrue(); + } + + public static void assertIsSpy(Object obj, String message) { + assertThat(isSpy(obj)).as("%s is a Mockito spy", message).isTrue(); + } + + public static void assertIsNotSpy(Object obj) { + assertThat(isSpy(obj)).as("is a Mockito spy").isFalse(); + } + + public static void assertIsNotSpy(Object obj, String message) { + assertThat(isSpy(obj)).as("%s is a Mockito spy", message).isFalse(); + } + + public static void assertMockName(Object mock, String name) { + MockName mockName = mockingDetails(mock).getMockCreationSettings().getMockName(); + assertThat(mockName.toString()).as("mock name").isEqualTo(name); + } + + private static boolean isMock(Object obj) { + return mockingDetails(obj).isMock(); + } + + private static boolean isSpy(Object obj) { + return mockingDetails(obj).isSpy(); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java b/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java index 73d84825d7f5..c61424039bd4 100644 --- a/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java +++ b/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java @@ -16,11 +16,22 @@ package org.springframework.test.util; +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; +import org.hamcrest.CoreMatchers; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.core.ParameterizedTypeReference; + +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.core.Is.is; /** @@ -28,10 +39,14 @@ * * @author Rossen Stoyanchev * @author Sam Brannen + * @author Stephane Nicoll * @since 3.2 */ class JsonPathExpectationsHelperTests { + private static final Configuration JACKSON_MAPPING_CONFIGURATION = Configuration.defaultConfiguration() + .mappingProvider(new JacksonMappingProvider(new ObjectMapper())); + private static final String CONTENT = """ { 'str': 'foo', @@ -324,4 +339,41 @@ void assertValueIsMapForNonMap() { .withMessageContaining("Expected a map at JSON path \"" + expression + "\" but found: 'foo'"); } + @Test + void assertValueWithComplexTypeFallbacksOnValueType() { + new JsonPathExpectationsHelper("$.familyMembers[0]", JACKSON_MAPPING_CONFIGURATION) + .assertValue(SIMPSONS, new Member("Homer")); + } + + @Test + void assertValueWithComplexTypeAndMatcher() { + new JsonPathExpectationsHelper("$.familyMembers[0]", JACKSON_MAPPING_CONFIGURATION) + .assertValue(SIMPSONS, CoreMatchers.instanceOf(Member.class), Member.class); + } + + @Test + void assertValueWithComplexGenericTypeAndMatcher() { + JsonPathExpectationsHelper helper = new JsonPathExpectationsHelper("$.familyMembers", JACKSON_MAPPING_CONFIGURATION); + helper.assertValue(SIMPSONS, hasSize(5), new ParameterizedTypeReference>() {}); + helper.assertValue(SIMPSONS, hasItem(new Member("Lisa")), new ParameterizedTypeReference>() {}); + } + + @Test + void evaluateJsonPathWithClassType() { + Member firstMember = new JsonPathExpectationsHelper("$.familyMembers[0]", JACKSON_MAPPING_CONFIGURATION) + .evaluateJsonPath(SIMPSONS, Member.class); + assertThat(firstMember).isEqualTo(new Member("Homer")); + } + + @Test + void evaluateJsonPathWithGenericType() { + List family = new JsonPathExpectationsHelper("$.familyMembers", JACKSON_MAPPING_CONFIGURATION) + .evaluateJsonPath(SIMPSONS, new ParameterizedTypeReference>() {}); + assertThat(family).containsExactly(new Member("Homer"), new Member("Marge"), + new Member("Bart"), new Member("Lisa"), new Member("Maggie")); + } + + + public record Member(String name) {} + } diff --git a/spring-test/src/test/java/org/springframework/test/util/MethodAssertTests.java b/spring-test/src/test/java/org/springframework/test/util/MethodAssertTests.java new file mode 100644 index 000000000000..17f294d4edaf --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/util/MethodAssertTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.util; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import org.springframework.lang.Nullable; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link MethodAssert}. + * + * @author Stephane Nicoll + */ +class MethodAssertTests { + + @Test + void isEqualTo() { + Method method = ReflectionUtils.findMethod(TestData.class, "counter"); + assertThat(method).isEqualTo(method); + } + + @Test + void hasName() { + assertThat(ReflectionUtils.findMethod(TestData.class, "counter")).hasName("counter"); + } + + @Test + void hasNameWithWrongName() { + Method method = ReflectionUtils.findMethod(TestData.class, "counter"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(method).hasName("invalid")) + .withMessageContainingAll("Method name", "counter", "invalid"); + } + + @Test + void hasNameWithNullMethod() { + Method method = ReflectionUtils.findMethod(TestData.class, "notAMethod"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(method).hasName("name")) + .withMessageContaining("Expecting actual not to be null"); + } + + @Test + void hasDeclaringClass() { + assertThat(ReflectionUtils.findMethod(TestData.class, "counter")).hasDeclaringClass(TestData.class); + } + + @Test + void haDeclaringClassWithWrongClass() { + Method method = ReflectionUtils.findMethod(TestData.class, "counter"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(method).hasDeclaringClass(Method.class)) + .withMessageContainingAll("Method declaring class", + TestData.class.getCanonicalName(), Method.class.getCanonicalName()); + } + + @Test + void hasDeclaringClassWithNullMethod() { + Method method = ReflectionUtils.findMethod(TestData.class, "notAMethod"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(method).hasDeclaringClass(TestData.class)) + .withMessageContaining("Expecting actual not to be null"); + } + + + private MethodAssert assertThat(@Nullable Method method) { + return new MethodAssert(method); + } + + + record TestData(String name, int counter) {} + +} diff --git a/spring-test/src/test/java/org/springframework/test/util/ReflectionTestUtilsTests.java b/spring-test/src/test/java/org/springframework/test/util/ReflectionTestUtilsTests.java index b8b95cc93b41..66d37c18a866 100644 --- a/spring-test/src/test/java/org/springframework/test/util/ReflectionTestUtilsTests.java +++ b/spring-test/src/test/java/org/springframework/test/util/ReflectionTestUtilsTests.java @@ -32,6 +32,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.springframework.test.util.ReflectionTestUtils.getField; import static org.springframework.test.util.ReflectionTestUtils.invokeGetterMethod; import static org.springframework.test.util.ReflectionTestUtils.invokeMethod; @@ -48,7 +49,7 @@ class ReflectionTestUtilsTests { private static final Float PI = (float) 22 / 7; - private final Person person = new PersonEntity(); + private final PersonEntity person = new PersonEntity(); private final Component component = new Component(); @@ -64,50 +65,50 @@ void resetStaticFields() { @Test void setFieldWithNullTargetObject() { assertThatIllegalArgumentException() - .isThrownBy(() -> setField((Object) null, "id", 99L)) - .withMessageStartingWith("Either targetObject or targetClass"); + .isThrownBy(() -> setField((Object) null, "id", 99L)) + .withMessageStartingWith("Either targetObject or targetClass"); } @Test void getFieldWithNullTargetObject() { assertThatIllegalArgumentException() - .isThrownBy(() -> getField((Object) null, "id")) - .withMessageStartingWith("Either targetObject or targetClass"); + .isThrownBy(() -> getField((Object) null, "id")) + .withMessageStartingWith("Either targetObject or targetClass"); } @Test void setFieldWithNullTargetClass() { assertThatIllegalArgumentException() - .isThrownBy(() -> setField(null, "id", 99L)) - .withMessageStartingWith("Either targetObject or targetClass"); + .isThrownBy(() -> setField(null, "id", 99L)) + .withMessageStartingWith("Either targetObject or targetClass"); } @Test void getFieldWithNullTargetClass() { assertThatIllegalArgumentException() - .isThrownBy(() -> getField(null, "id")) - .withMessageStartingWith("Either targetObject or targetClass"); + .isThrownBy(() -> getField(null, "id")) + .withMessageStartingWith("Either targetObject or targetClass"); } @Test void setFieldWithNullNameAndNullType() { assertThatIllegalArgumentException() - .isThrownBy(() -> setField(person, null, 99L, null)) - .withMessageStartingWith("Either name or type"); + .isThrownBy(() -> setField(person, null, 99L, null)) + .withMessageStartingWith("Either name or type"); } @Test void setFieldWithBogusName() { assertThatIllegalArgumentException() - .isThrownBy(() -> setField(person, "bogus", 99L, long.class)) - .withMessageStartingWith("Could not find field 'bogus'"); + .isThrownBy(() -> setField(person, "bogus", 99L, long.class)) + .withMessageStartingWith("Could not find field 'bogus'"); } @Test void setFieldWithWrongType() { assertThatIllegalArgumentException() - .isThrownBy(() -> setField(person, "id", 99L, String.class)) - .withMessageStartingWith("Could not find field"); + .isThrownBy(() -> setField(person, "id", 99L, String.class)) + .withMessageStartingWith("Could not find field"); } @Test @@ -156,7 +157,7 @@ private static void assertSetFieldAndGetFieldBehavior(Person person) { assertThat(person.getAge()).as("age (private field)").isEqualTo(42); assertThat(person.getEyeColor()).as("eye color (package private field)").isEqualTo("blue"); assertThat(person.likesPets()).as("'likes pets' flag (package private boolean field)").isTrue(); - assertThat(person.getFavoriteNumber()).as("'favorite number' (package field)").isEqualTo(PI); + assertThat(person.getFavoriteNumber()).as("'favorite number' (private field)").isEqualTo(PI); } private static void assertSetFieldAndGetFieldBehaviorForProxy(Person proxy, Person target) { @@ -168,7 +169,7 @@ private static void assertSetFieldAndGetFieldBehaviorForProxy(Person proxy, Pers assertThat(target.getAge()).as("age (private field)").isEqualTo(42); assertThat(target.getEyeColor()).as("eye color (package private field)").isEqualTo("blue"); assertThat(target.likesPets()).as("'likes pets' flag (package private boolean field)").isTrue(); - assertThat(target.getFavoriteNumber()).as("'favorite number' (package field)").isEqualTo(PI); + assertThat(target.getFavoriteNumber()).as("'favorite number' (private field)").isEqualTo(PI); } @Test @@ -188,7 +189,7 @@ void setFieldWithNullValuesForNonPrimitives() { assertThat(person.getName()).as("name (protected field)").isNull(); assertThat(person.getEyeColor()).as("eye color (package private field)").isNull(); - assertThat(person.getFavoriteNumber()).as("'favorite number' (package field)").isNull(); + assertThat(person.getFavoriteNumber()).as("'favorite number' (private field)").isNull(); } @Test @@ -295,6 +296,22 @@ void invokeSetterMethodAndInvokeGetterMethodWithJavaBeanPropertyNames() { assertThat(invokeGetterMethod(person, "favoriteNumber")).isEqualTo(PI); } + @Test // gh-33429 + void invokingPrivateGetterMethodViaCglibProxyInvokesMethodOnUltimateTarget() { + this.person.setPrivateEye("I"); + ProxyFactory pf = new ProxyFactory(this.person); + pf.setProxyTargetClass(true); + PersonEntity proxy = (PersonEntity) pf.getProxy(); + assertThat(AopUtils.isCglibProxy(proxy)).as("Proxy is a CGLIB proxy").isTrue(); + + assertSoftly(softly -> { + softly.assertThat(getField(this.person, "privateEye")).as("'privateEye' (private field in target)").isEqualTo("I"); + softly.assertThat(getField(proxy, "privateEye")).as("'privateEye' (private field in proxy)").isEqualTo("I"); + softly.assertThat(invokeGetterMethod(this.person, "privateEye")).as("'privateEye' (getter on target)").isEqualTo("I"); + softly.assertThat(invokeGetterMethod(proxy, "privateEye")).as("'privateEye' (getter on proxy)").isEqualTo("I"); + }); + } + @Test void invokeSetterMethodWithNullValuesForNonPrimitives() { invokeSetterMethod(person, "name", null, String.class); @@ -306,6 +323,41 @@ void invokeSetterMethodWithNullValuesForNonPrimitives() { assertThat(person.getFavoriteNumber()).as("'favorite number' (protected method for a Number)").isNull(); } + @Test // gh-33429 + void invokingPrivateSetterMethodViaCglibProxyInvokesMethodOnUltimateTarget() { + ProxyFactory pf = new ProxyFactory(this.person); + pf.setProxyTargetClass(true); + PersonEntity proxy = (PersonEntity) pf.getProxy(); + assertThat(AopUtils.isCglibProxy(proxy)).as("Proxy is a CGLIB proxy").isTrue(); + + // Set reflectively + invokeSetterMethod(proxy, "favoriteNumber", PI, Number.class); + + assertSoftly(softly -> { + softly.assertThat(getField(proxy, "favoriteNumber")).as("'favorite number' (private field)").isEqualTo(PI); + softly.assertThat(proxy.getFavoriteNumber()).as("'favorite number' (getter on proxy)").isEqualTo(PI); + softly.assertThat(this.person.getFavoriteNumber()).as("'favorite number' (getter on target)").isEqualTo(PI); + }); + } + + @Test // gh-33429 + void invokingFinalSetterMethodViaCglibProxyInvokesMethodOnUltimateTarget() { + ProxyFactory pf = new ProxyFactory(this.person); + pf.setProxyTargetClass(true); + PersonEntity proxy = (PersonEntity) pf.getProxy(); + assertThat(AopUtils.isCglibProxy(proxy)).as("Proxy is a CGLIB proxy").isTrue(); + assertThat(proxy.getPuzzle()).as("puzzle").isNull(); + + // Set reflectively + invokeSetterMethod(proxy, "puzzle", "enigma", String.class); + + assertSoftly(softly -> { + softly.assertThat(getField(proxy, "puzzle")).as("'puzzle' (private field)").isEqualTo("enigma"); + softly.assertThat(proxy.getPuzzle()).as("'puzzle' (getter on proxy)").isEqualTo("enigma"); + softly.assertThat(this.person.getPuzzle()).as("'puzzle' (getter on target)").isEqualTo("enigma"); + }); + } + @Test void invokeSetterMethodWithNullValueForPrimitiveLong() { assertThatIllegalArgumentException().isThrownBy(() -> invokeSetterMethod(person, "id", null, long.class)); @@ -362,32 +414,49 @@ void invokeMethodSimulatingLifecycleEvents() { assertThat(component.getText()).as("text").isNull(); } + @Test // gh-33429 + void invokingPrivateMethodViaCglibProxyInvokesMethodOnUltimateTarget() { + ProxyFactory pf = new ProxyFactory(this.person); + pf.setProxyTargetClass(true); + PersonEntity proxy = (PersonEntity) pf.getProxy(); + assertThat(AopUtils.isCglibProxy(proxy)).as("Proxy is a CGLIB proxy").isTrue(); + + // Set reflectively + invokeMethod(proxy, "setFavoriteNumber", PI); + + assertSoftly(softly -> { + softly.assertThat(getField(proxy, "favoriteNumber")).as("'favorite number' (private field)").isEqualTo(PI); + softly.assertThat(proxy.getFavoriteNumber()).as("'favorite number' (getter on proxy)").isEqualTo(PI); + softly.assertThat(this.person.getFavoriteNumber()).as("'favorite number' (getter on target)").isEqualTo(PI); + }); + } + @Test void invokeInitMethodBeforeAutowiring() { assertThatIllegalStateException() - .isThrownBy(() -> invokeMethod(component, "init")) - .withMessageStartingWith("number must not be null"); + .isThrownBy(() -> invokeMethod(component, "init")) + .withMessageStartingWith("number must not be null"); } @Test void invokeMethodWithIncompatibleArgumentTypes() { assertThatIllegalStateException() - .isThrownBy(() -> invokeMethod(component, "subtract", "foo", 2.0)) - .withMessageStartingWith("Method not found"); + .isThrownBy(() -> invokeMethod(component, "subtract", "foo", 2.0)) + .withMessageStartingWith("Method not found"); } @Test void invokeMethodWithTooFewArguments() { assertThatIllegalStateException() - .isThrownBy(() -> invokeMethod(component, "configure", 42)) - .withMessageStartingWith("Method not found"); + .isThrownBy(() -> invokeMethod(component, "configure", 42)) + .withMessageStartingWith("Method not found"); } @Test void invokeMethodWithTooManyArguments() { assertThatIllegalStateException() - .isThrownBy(() -> invokeMethod(component, "configure", 42, "enigma", "baz", "quux")) - .withMessageStartingWith("Method not found"); + .isThrownBy(() -> invokeMethod(component, "configure", 42, "enigma", "baz", "quux")) + .withMessageStartingWith("Method not found"); } @Test // SPR-14363 @@ -400,7 +469,7 @@ void getFieldOnLegacyEntityWithSideEffectsInToString() { void setFieldOnLegacyEntityWithSideEffectsInToString() { String testCollaborator = "test collaborator"; setField(entity, "collaborator", testCollaborator, Object.class); - assertThat(entity.toString()).contains(testCollaborator); + assertThat(entity).asString().contains(testCollaborator); } @Test // SPR-14363 @@ -420,28 +489,28 @@ void invokeGetterMethodOnLegacyEntityWithSideEffectsInToString() { void invokeSetterMethodOnLegacyEntityWithSideEffectsInToString() { String testCollaborator = "test collaborator"; invokeSetterMethod(entity, "collaborator", testCollaborator); - assertThat(entity.toString()).contains(testCollaborator); + assertThat(entity).asString().contains(testCollaborator); } @Test void invokeStaticMethodWithNullTargetClass() { assertThatIllegalArgumentException() - .isThrownBy(() -> invokeMethod(null, null)) - .withMessage("Target class must not be null"); + .isThrownBy(() -> invokeMethod(null, null)) + .withMessage("Target class must not be null"); } @Test void invokeStaticMethodWithNullMethodName() { assertThatIllegalArgumentException() - .isThrownBy(() -> invokeMethod(getClass(), null)) - .withMessage("Method name must not be empty"); + .isThrownBy(() -> invokeMethod(getClass(), null)) + .withMessage("Method name must not be empty"); } @Test void invokeStaticMethodWithEmptyMethodName() { assertThatIllegalArgumentException() - .isThrownBy(() -> invokeMethod(getClass(), " ")) - .withMessage("Method name must not be empty"); + .isThrownBy(() -> invokeMethod(getClass(), " ")) + .withMessage("Method name must not be empty"); } @Test @@ -481,8 +550,8 @@ void invokePrivateStaticMethodWithoutArguments() { @Test void invokeStaticMethodWithNullTargetObjectAndNullTargetClass() { assertThatIllegalArgumentException() - .isThrownBy(() -> invokeMethod(null, (Class) null, "id")) - .withMessage("Either 'targetObject' or 'targetClass' for the method must be specified"); + .isThrownBy(() -> invokeMethod(null, (Class) null, "id")) + .withMessage("Either 'targetObject' or 'targetClass' for the method must be specified"); } } diff --git a/spring-test/src/test/java/org/springframework/test/util/XmlExpectationsHelperTests.java b/spring-test/src/test/java/org/springframework/test/util/XmlExpectationsHelperTests.java index eae8b99b2d2f..d9efbcf003a1 100644 --- a/spring-test/src/test/java/org/springframework/test/util/XmlExpectationsHelperTests.java +++ b/spring-test/src/test/java/org/springframework/test/util/XmlExpectationsHelperTests.java @@ -27,51 +27,56 @@ */ class XmlExpectationsHelperTests { + private static final String CONTROL = "f1f2"; + + private final XmlExpectationsHelper xmlHelper = new XmlExpectationsHelper(); + + @Test void assertXmlEqualForEqual() throws Exception { - String control = "f1f2"; String test = "f1f2"; - XmlExpectationsHelper xmlHelper = new XmlExpectationsHelper(); - xmlHelper.assertXmlEqual(control, test); + xmlHelper.assertXmlEqual(CONTROL, test); } @Test void assertXmlEqualExceptionForIncorrectValue() { - String control = "f1f2"; String test = "notf1f2"; - XmlExpectationsHelper xmlHelper = new XmlExpectationsHelper(); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - xmlHelper.assertXmlEqual(control, test)) - .withMessageStartingWith("Body content Expected child 'field1'"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> xmlHelper.assertXmlEqual(CONTROL, test)) + .withMessageStartingWith("Body content Expected child 'field1'"); } @Test void assertXmlEqualForOutOfOrder() throws Exception { - String control = "f1f2"; String test = "f2f1"; - XmlExpectationsHelper xmlHelper = new XmlExpectationsHelper(); - xmlHelper.assertXmlEqual(control, test); + xmlHelper.assertXmlEqual(CONTROL, test); } @Test void assertXmlEqualExceptionForMoreEntries() { - String control = "f1f2"; String test = "f1f2f3"; - XmlExpectationsHelper xmlHelper = new XmlExpectationsHelper(); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - xmlHelper.assertXmlEqual(control, test)) - .withMessageContaining("Expected child nodelist length '2' but was '3'"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> xmlHelper.assertXmlEqual(CONTROL, test)) + .withMessageContaining("Expected child nodelist length '2' but was '3'"); } @Test - void assertXmlEqualExceptionForLessEntries() { + void assertXmlEqualExceptionForFewerEntries() { String control = "f1f2f3"; String test = "f1f2"; - XmlExpectationsHelper xmlHelper = new XmlExpectationsHelper(); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - xmlHelper.assertXmlEqual(control, test)) - .withMessageContaining("Expected child nodelist length '3' but was '2'"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> xmlHelper.assertXmlEqual(control, test)) + .withMessageContaining("Expected child nodelist length '3' but was '2'"); + } + + @Test + void assertXmlEqualExceptionWithFullDescription() { + String test = "f2f3"; + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> xmlHelper.assertXmlEqual(CONTROL, test)) + .withMessageContaining("Expected child 'field1' but was 'null'") + .withMessageContaining("Expected child 'null' but was 'field3'"); } } diff --git a/spring-test/src/test/java/org/springframework/test/util/subpackage/PersonEntity.java b/spring-test/src/test/java/org/springframework/test/util/subpackage/PersonEntity.java index 45b841983e39..e247e92d85e3 100644 --- a/spring-test/src/test/java/org/springframework/test/util/subpackage/PersonEntity.java +++ b/spring-test/src/test/java/org/springframework/test/util/subpackage/PersonEntity.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2024 the original author 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,10 @@ public class PersonEntity extends PersistentEntity implements Person { private Number favoriteNumber; + private String puzzle; + + private String privateEye; + @Override public String getName() { @@ -44,7 +48,7 @@ public String getName() { } @SuppressWarnings("unused") - private void setName(final String name) { + private void setName(String name) { this.name = name; } @@ -53,7 +57,7 @@ public int getAge() { return this.age; } - protected void setAge(final int age) { + protected void setAge(int age) { this.age = age; } @@ -62,7 +66,7 @@ public String getEyeColor() { return this.eyeColor; } - void setEyeColor(final String eyeColor) { + void setEyeColor(String eyeColor) { this.eyeColor = eyeColor; } @@ -71,7 +75,7 @@ public boolean likesPets() { return this.likesPets; } - protected void setLikesPets(final boolean likesPets) { + protected void setLikesPets(boolean likesPets) { this.likesPets = likesPets; } @@ -80,10 +84,28 @@ public Number getFavoriteNumber() { return this.favoriteNumber; } - protected void setFavoriteNumber(Number favoriteNumber) { + @SuppressWarnings("unused") + private void setFavoriteNumber(Number favoriteNumber) { this.favoriteNumber = favoriteNumber; } + public String getPuzzle() { + return this.puzzle; + } + + public final void setPuzzle(String puzzle) { + this.puzzle = puzzle; + } + + @SuppressWarnings("unused") + private String getPrivateEye() { + return this.privateEye; + } + + public void setPrivateEye(String privateEye) { + this.privateEye = privateEye; + } + @Override public String toString() { // @formatter:off @@ -94,6 +116,8 @@ public String toString() { .append("eyeColor", this.eyeColor) .append("likesPets", this.likesPets) .append("favoriteNumber", this.favoriteNumber) + .append("puzzle", this.puzzle) + .append("privateEye", this.privateEye) .toString(); // @formatter:on } diff --git a/spring-test/src/test/java/org/springframework/test/validation/AbstractBindingResultAssertTests.java b/spring-test/src/test/java/org/springframework/test/validation/AbstractBindingResultAssertTests.java new file mode 100644 index 000000000000..39ee91e23494 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/validation/AbstractBindingResultAssertTests.java @@ -0,0 +1,136 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.validation; + +import java.util.Map; + +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.MutablePropertyValues; +import org.springframework.beans.testfixture.beans.TestBean; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.DataBinder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link AbstractBindingResultAssert}. + * + * @author Stephane Nicoll + */ +class AbstractBindingResultAssertTests { + + @Test + void hasErrorsCountWithNoError() { + assertThat(bindingResult(new TestBean(), Map.of("name", "John", "age", "42"))).hasErrorsCount(0); + } + + @Test + void hasErrorsCountWithInvalidCount() { + AssertProvider actual = bindingResult(new TestBean(), + Map.of("name", "John", "age", "4x", "touchy", "invalid.value")); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).hasErrorsCount(1)) + .withMessageContainingAll("check errors for attribute 'test'", "1", "2"); + } + + @Test + void hasFieldErrorsWithMatchingSubset() { + assertThat(bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y"))) + .hasFieldErrors("touchy"); + } + + @Test + void hasFieldErrorsWithAllMatching() { + assertThat(bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y"))) + .hasFieldErrors("touchy", "age"); + } + + @Test + void hasFieldErrorsWithNotAllMatching() { + AssertProvider actual = bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y")); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).hasFieldErrors("age", "name")) + .withMessageContainingAll("check field errors", "age", "touchy", "name"); + } + + @Test + void hasOnlyFieldErrorsWithAllMatching() { + assertThat(bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y"))) + .hasOnlyFieldErrors("touchy", "age"); + } + + @Test + void hasOnlyFieldErrorsWithMatchingSubset() { + AssertProvider actual = bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y")); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).hasOnlyFieldErrors("age")) + .withMessageContainingAll("check field errors", "age", "touchy"); + } + + @Test + void hasFieldErrorCodeWithMatchingCode() { + assertThat(bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y"))) + .hasFieldErrorCode("age", "typeMismatch"); + } + + @Test + void hasFieldErrorCodeWitNonMatchingCode() { + AssertProvider actual = bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y")); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).hasFieldErrorCode("age", "castFailure")) + .withMessageContainingAll("check error code for field 'age'", "castFailure", "typeMismatch"); + } + + @Test + void hasFieldErrorCodeWitNonMatchingField() { + AssertProvider actual = bindingResult(new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "x.y")); + assertThatExceptionOfType(AssertionError.class).isThrownBy( + () -> assertThat(actual).hasFieldErrorCode("unknown", "whatever")) + .withMessageContainingAll("Expecting binding result", "touchy", "age", + "to have at least an error for field 'unknown'"); + } + + + private AssertProvider bindingResult(Object instance, Map propertyValues) { + return () -> new BindingResultAssert("test", createBindingResult(instance, propertyValues)); + } + + private static BindingResult createBindingResult(Object instance, Map propertyValues) { + DataBinder binder = new DataBinder(instance, "test"); + MutablePropertyValues pvs = new MutablePropertyValues(propertyValues); + binder.bind(pvs); + try { + binder.close(); + return binder.getBindingResult(); + } + catch (BindException ex) { + return ex.getBindingResult(); + } + } + + + private static final class BindingResultAssert extends AbstractBindingResultAssert { + public BindingResultAssert(String name, BindingResult bindingResult) { + super(name, bindingResult, BindingResultAssert.class); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/UriAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/UriAssertTests.java new file mode 100644 index 000000000000..359a01f00764 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/UriAssertTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link UriAssert}. + * + * @author Stephane Nicoll + */ +class UriAssertTests { + + @Test + void isEqualToTemplate() { + assertThat("/orders/1/items/2").isEqualToTemplate("/orders/{orderId}/items/{itemId}", 1, 2); + } + + @Test + void isEqualToTemplateWithWrongValue() { + String expected = "/orders/1/items/3"; + String actual = "/orders/1/items/2"; + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(expected).isEqualToTemplate("/orders/{orderId}/items/{itemId}", 1, 2)) + .withMessageContainingAll("Test URI", expected, actual); + } + + @Test + void isEqualToTemplateMissingArg() { + String template = "/orders/{orderId}/items/{itemId}"; + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat("/orders/1/items/2").isEqualToTemplate(template, 1)) + .withMessageContainingAll("Expecting:", template, + "Not enough variable values available to expand 'itemId'"); + } + + @Test + void matchesAntPattern() { + assertThat("/orders/1").matchesAntPattern("/orders/*"); + } + + @Test + void matchesAntPatternWithNonValidPattern() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat("/orders/1").matchesAntPattern("/orders/")) + .withMessage("'/orders/' is not an Ant-style path pattern"); + } + + @Test + void matchesAntPatternWithWrongValue() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat("/orders/1").matchesAntPattern("/resources/*")) + .withMessageContainingAll("Test URI", "/resources/*", "/orders/1"); + } + + + UriAssert assertThat(String uri) { + return new UriAssert(uri, "Test URI"); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/client/match/ContentRequestMatchersTests.java b/spring-test/src/test/java/org/springframework/test/web/client/match/ContentRequestMatchersTests.java index a14b288408b0..ee76368ab92d 100644 --- a/spring-test/src/test/java/org/springframework/test/web/client/match/ContentRequestMatchersTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/client/match/ContentRequestMatchersTests.java @@ -18,11 +18,13 @@ import java.nio.charset.StandardCharsets; import java.util.Collections; +import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.test.json.JsonCompareMode; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -124,6 +126,76 @@ public void testFormDataContains() throws Exception { .match(this.request); } + @Test + public void testMultipartData() throws Exception { + String contentType = "multipart/form-data;boundary=1234567890"; + String body = """ + --1234567890\r + Content-Disposition: form-data; name="name 1"\r + \r + vølue 1\r + --1234567890\r + Content-Disposition: form-data; name="name 2"\r + \r + value 🙂\r + --1234567890\r + Content-Disposition: form-data; name="name 3"\r + \r + value 漢字\r + --1234567890\r + Content-Disposition: form-data; name="name 4"\r + \r + \r + --1234567890--\r + """; + + this.request.getHeaders().setContentType(MediaType.parseMediaType(contentType)); + this.request.getBody().write(body.getBytes(StandardCharsets.UTF_8)); + + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("name 1", "vølue 1"); + map.add("name 2", "value 🙂"); + map.add("name 3", "value 漢字"); + map.add("name 4", ""); + MockRestRequestMatchers.content().multipartData(map).match(this.request); + } + + @Test + public void testMultipartDataContains() throws Exception { + String contentType = "multipart/form-data;boundary=1234567890"; + String body = """ + --1234567890\r + Content-Disposition: form-data; name="name 1"\r + \r + vølue 1\r + --1234567890\r + Content-Disposition: form-data; name="name 2"\r + \r + value 🙂\r + --1234567890\r + Content-Disposition: form-data; name="name 3"\r + \r + value 漢字\r + --1234567890\r + Content-Disposition: form-data; name="name 4"\r + \r + \r + --1234567890--\r + """; + + this.request.getHeaders().setContentType(MediaType.parseMediaType(contentType)); + this.request.getBody().write(body.getBytes(StandardCharsets.UTF_8)); + + MockRestRequestMatchers.content() + .multipartDataContains(Map.of( + "name 1", "vølue 1", + "name 2", "value 🙂", + "name 3", "value 漢字", + "name 4", "") + ) + .match(this.request); + } + @Test public void testXml() throws Exception { String content = "bazbazz"; @@ -163,6 +235,16 @@ public void testJsonLenientMatch() throws Exception { MockRestRequestMatchers.content().json("{\n \"foo array\":[\"second\",\"first\"] \n}") .match(this.request); + MockRestRequestMatchers.content().json("{\n \"foo array\":[\"second\",\"first\"] \n}", JsonCompareMode.LENIENT) + .match(this.request); + } + + @Test + @Deprecated + public void testJsonLenientMatchWithDeprecatedBooleanFlag() throws Exception { + String content = "{\n \"foo array\":[\"first\",\"second\"] , \"someExtraProperty\": \"which is allowed\" \n}"; + this.request.getBody().write(content.getBytes()); + MockRestRequestMatchers.content().json("{\n \"foo array\":[\"second\",\"first\"] \n}", false) .match(this.request); } @@ -172,6 +254,18 @@ public void testJsonStrictMatch() throws Exception { String content = "{\n \"foo\": \"bar\", \"foo array\":[\"first\",\"second\"] \n}"; this.request.getBody().write(content.getBytes()); + MockRestRequestMatchers + .content() + .json("{\n \"foo array\":[\"first\",\"second\"] , \"foo\": \"bar\" \n}", JsonCompareMode.STRICT) + .match(this.request); + } + + @Test + @Deprecated + public void testJsonStrictMatchWithDeprecatedBooleanFlag() throws Exception { + String content = "{\n \"foo\": \"bar\", \"foo array\":[\"first\",\"second\"] \n}"; + this.request.getBody().write(content.getBytes()); + MockRestRequestMatchers .content() .json("{\n \"foo array\":[\"first\",\"second\"] , \"foo\": \"bar\" \n}", true) @@ -188,6 +282,19 @@ public void testJsonLenientNoMatch() throws Exception { .content() .json("{\n \"foo\" : \"bar\" \n}") .match(this.request)); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + MockRestRequestMatchers + .content() + .json("{\n \"foo\" : \"bar\" \n}", JsonCompareMode.LENIENT) + .match(this.request)); + } + + @Test + @Deprecated + public void testJsonLenientNoMatchWithDeprecatedBooleanFlag() throws Exception { + String content = "{\n \"bar\" : \"foo\" \n}"; + this.request.getBody().write(content.getBytes()); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> MockRestRequestMatchers .content() @@ -200,6 +307,19 @@ public void testJsonStrictNoMatch() throws Exception { String content = "{\n \"foo array\":[\"first\",\"second\"] , \"someExtraProperty\": \"which is NOT allowed\" \n}"; this.request.getBody().write(content.getBytes()); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + MockRestRequestMatchers + .content() + .json("{\n \"foo array\":[\"second\",\"first\"] \n}", JsonCompareMode.STRICT) + .match(this.request)); + } + + @Test + @Deprecated + public void testJsonStrictNoMatchWithDeprecatedBooleanFlag() throws Exception { + String content = "{\n \"foo array\":[\"first\",\"second\"] , \"someExtraProperty\": \"which is NOT allowed\" \n}"; + this.request.getBody().write(content.getBytes()); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> MockRestRequestMatchers .content() diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionsTests.java similarity index 94% rename from spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionTests.java rename to spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionsTests.java index c3eedbe6456a..a8c6ca455bfe 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionsTests.java @@ -37,7 +37,7 @@ * * @author Rossen Stoyanchev */ -public class CookieAssertionTests { +public class CookieAssertionsTests { private final ResponseCookie cookie = ResponseCookie.from("foo", "bar") .maxAge(Duration.ofMinutes(30)) @@ -45,6 +45,7 @@ public class CookieAssertionTests { .path("/foo") .secure(true) .httpOnly(true) + .partitioned(true) .sameSite("Lax") .build(); @@ -117,6 +118,12 @@ void httpOnly() { assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.httpOnly("foo", false)); } + @Test + void partitioned() { + assertions.partitioned("foo", true); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.partitioned("foo", false)); + } + @Test void sameSite() { assertions.sameSite("foo", "Lax"); diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java new file mode 100644 index 000000000000..e2c9d27a37fb --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.TypeRef; +import org.junit.jupiter.api.Test; + +import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.codec.json.Jackson2JsonEncoder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link EncoderDecoderMappingProvider}. + * + * @author Stephane Nicoll + */ +class EncoderDecoderMappingProviderTests { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private final EncoderDecoderMappingProvider mappingProvider = new EncoderDecoderMappingProvider( + new Jackson2JsonEncoder(objectMapper), new Jackson2JsonDecoder(objectMapper)); + + + @Test + void mapType() { + Data data = this.mappingProvider.map(jsonData("test", 42), Data.class, Configuration.defaultConfiguration()); + assertThat(data).isEqualTo(new Data("test", 42)); + } + + @Test + void mapGenericType() { + List jsonData = List.of(jsonData("first", 1), jsonData("second", 2), jsonData("third", 3)); + List data = this.mappingProvider.map(jsonData, new TypeRef>() {}, Configuration.defaultConfiguration()); + assertThat(data).containsExactly(new Data("first", 1), new Data("second", 2), new Data("third", 3)); + } + + private Map jsonData(String name, int counter) { + return Map.of("name", name, "counter", counter); + } + + + record Data(String name, int counter) {} + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/HeaderAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/HeaderAssertionTests.java index fcf647e6d464..6c0362831a49 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/HeaderAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/HeaderAssertionTests.java @@ -105,6 +105,17 @@ void valueMatches() { "[.*ISO-8859-1.*]")); } + @Test + void valueMatchesWithNonexistentHeader() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/json;charset=UTF-8")); + HeaderAssertions assertions = headerAssertions(headers); + + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valueMatches("Content-XYZ", ".*ISO-8859-1.*")) + .withMessage("Response header 'Content-XYZ' not found"); + } + @Test void valuesMatch() { HttpHeaders headers = new HttpHeaders(); diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java new file mode 100644 index 000000000000..6655b071f7f6 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java @@ -0,0 +1,80 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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; + +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import org.springframework.http.codec.DecoderHttpMessageReader; +import org.springframework.http.codec.EncoderHttpMessageWriter; +import org.springframework.http.codec.HttpMessageReader; +import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.http.codec.ResourceHttpMessageReader; +import org.springframework.http.codec.ResourceHttpMessageWriter; +import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.codec.json.Jackson2JsonEncoder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JsonEncoderDecoder}. + * + * @author Stephane Nicoll + */ +class JsonEncoderDecoderTests { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private static final HttpMessageWriter jacksonMessageWriter = new EncoderHttpMessageWriter<>( + new Jackson2JsonEncoder(objectMapper)); + + private static final HttpMessageReader jacksonMessageReader = new DecoderHttpMessageReader<>( + new Jackson2JsonDecoder(objectMapper)); + + @Test + void fromWithEmptyWriters() { + assertThat(JsonEncoderDecoder.from(List.of(), List.of(jacksonMessageReader))).isNull(); + } + + @Test + void fromWithEmptyReaders() { + assertThat(JsonEncoderDecoder.from(List.of(jacksonMessageWriter), List.of())).isNull(); + } + + @Test + void fromWithSuitableWriterAndNoReader() { + assertThat(JsonEncoderDecoder.from(List.of(jacksonMessageWriter), List.of(new ResourceHttpMessageReader()))).isNull(); + } + + @Test + void fromWithSuitableReaderAndNoWriter() { + assertThat(JsonEncoderDecoder.from(List.of(new ResourceHttpMessageWriter()), List.of(jacksonMessageReader))).isNull(); + } + + @Test + void fromWithNoSuitableReaderAndWriter() { + JsonEncoderDecoder jsonEncoderDecoder = JsonEncoderDecoder.from( + List.of(new ResourceHttpMessageWriter(), jacksonMessageWriter), + List.of(new ResourceHttpMessageReader(), jacksonMessageReader)); + assertThat(jsonEncoderDecoder).isNotNull(); + assertThat(jsonEncoderDecoder.encoder()).isInstanceOf(Jackson2JsonEncoder.class); + assertThat(jsonEncoderDecoder.decoder()).isInstanceOf(Jackson2JsonDecoder.class); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ExchangeMutatorTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ExchangeMutatorTests.java index 759770bf6528..d90c55690c55 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ExchangeMutatorTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ExchangeMutatorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -114,7 +114,7 @@ public void afterConfigurerAdded(WebTestClient.Builder builder, Assert.notNull(httpHandlerBuilder, "Not a mock server"); httpHandlerBuilder.filters(filters -> { - filters.removeIf(filter -> filter instanceof IdentityFilter); + filters.removeIf(IdentityFilter.class::isInstance); filters.add(0, this.filter); }); } diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/JsonContentTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/JsonContentTests.java index 31d8efd3693a..ffd33380ffe5 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/JsonContentTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/JsonContentTests.java @@ -23,6 +23,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.test.json.JsonCompareMode; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -74,7 +75,7 @@ void jsonContentWithStrictMode() { {"firstName":"John", "lastName":"Smith"} ] """, - true); + JsonCompareMode.STRICT); } @Test @@ -89,7 +90,7 @@ void jsonContentWithStrictModeAndMissingAttributes() { {"firstName":"John"} ] """, - true) + JsonCompareMode.STRICT) ); } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssertTests.java new file mode 100644 index 000000000000..32aa6194f453 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletRequestAssertTests.java @@ -0,0 +1,142 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.util.LinkedHashMap; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; + +import static java.util.Map.entry; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link AbstractHttpServletRequestAssert}. + * + * @author Stephane Nicoll + */ +public class AbstractHttpServletRequestAssertTests { + + @Nested + class AttributesTests { + + @Test + void attributesAreCopied() { + Map map = new LinkedHashMap<>(); + map.put("one", 1); + map.put("two", 2); + assertThat(createRequest(map)).attributes() + .containsExactly(entry("one", 1), entry("two", 2)); + } + + @Test + void attributesWithWrongKey() { + HttpServletRequest request = createRequest(Map.of("one", 1)); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(request).attributes().containsKey("two")) + .withMessageContainingAll("Request Attributes", "two", "one"); + } + + private HttpServletRequest createRequest(Map attributes) { + MockHttpServletRequest request = new MockHttpServletRequest(); + attributes.forEach(request::setAttribute); + return request; + } + + } + + @Nested + class SessionAttributesTests { + + @Test + void sessionAttributesAreCopied() { + Map map = new LinkedHashMap<>(); + map.put("one", 1); + map.put("two", 2); + assertThat(createRequest(map)).sessionAttributes() + .containsExactly(entry("one", 1), entry("two", 2)); + } + + @Test + void sessionAttributesWithWrongKey() { + HttpServletRequest request = createRequest(Map.of("one", 1)); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(request).sessionAttributes().containsKey("two")) + .withMessageContainingAll("Session Attributes", "two", "one"); + } + + + private HttpServletRequest createRequest(Map attributes) { + MockHttpServletRequest request = new MockHttpServletRequest(); + HttpSession session = request.getSession(); + attributes.forEach(session::setAttribute); + return request; + } + + } + + @Test + void hasAsyncStartedTrue() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAsyncStarted(true); + assertThat(request).hasAsyncStarted(true); + } + + @Test + void hasAsyncStartedTrueWithFalse() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAsyncStarted(false); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(request).hasAsyncStarted(true)) + .withMessage("Async expected to have started"); + } + + @Test + void hasAsyncStartedFalse() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAsyncStarted(false); + assertThat(request).hasAsyncStarted(false); + } + + @Test + void hasAsyncStartedFalseWithTrue() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAsyncStarted(true); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(request).hasAsyncStarted(false)) + .withMessage("Async expected not to have started"); + } + + + private static RequestAssert assertThat(HttpServletRequest request) { + return new RequestAssert(request); + } + + + private static final class RequestAssert extends AbstractHttpServletRequestAssert { + + RequestAssert(HttpServletRequest actual) { + super(actual, RequestAssert.class); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssertTests.java new file mode 100644 index 000000000000..1344ada36537 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractHttpServletResponseAssertTests.java @@ -0,0 +1,197 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.util.Map; + +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link AbstractHttpServletResponseAssert}. + * + * @author Stephane Nicoll + */ +class AbstractHttpServletResponseAssertTests { + + @Nested + class HeadersTests { + + @Test + void containsHeader() { + MockHttpServletResponse response = createResponse(Map.of("n1", "v1", "n2", "v2", "n3", "v3")); + assertThat(response).containsHeader("n1"); + } + + @Test + void doesNotContainHeader() { + MockHttpServletResponse response = createResponse(Map.of("n1", "v1", "n2", "v2", "n3", "v3")); + assertThat(response).doesNotContainHeader("n4"); + } + + @Test + void hasHeader() { + MockHttpServletResponse response = createResponse(Map.of("n1", "v1", "n2", "v2", "n3", "v3")); + assertThat(response).hasHeader("n1", "v1"); + } + + @Test + void headersAreMatching() { + MockHttpServletResponse response = createResponse(Map.of("n1", "v1", "n2", "v2", "n3", "v3")); + assertThat(response).headers().containsHeaders("n1", "n2", "n3"); + } + + private MockHttpServletResponse createResponse(Map headers) { + MockHttpServletResponse response = new MockHttpServletResponse(); + headers.forEach(response::addHeader); + return response; + } + } + + @Nested + class ContentTypeTests { + + @Test + void contentType() { + MockHttpServletResponse response = createResponse("text/plain"); + assertThat(response).hasContentType(MediaType.TEXT_PLAIN); + } + + @Test + void contentTypeAndRepresentation() { + MockHttpServletResponse response = createResponse("text/plain"); + assertThat(response).hasContentType("text/plain"); + } + + @Test + void contentTypeCompatibleWith() { + MockHttpServletResponse response = createResponse("application/json;charset=UTF-8"); + assertThat(response).hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON); + } + + @Test + void contentTypeCompatibleWithAndStringRepresentation() { + MockHttpServletResponse response = createResponse("text/plain"); + assertThat(response).hasContentTypeCompatibleWith("text/*"); + } + + @Test + void contentTypeCanBeAsserted() { + MockHttpServletResponse response = createResponse("text/plain"); + assertThat(response).contentType().isInstanceOf(MediaType.class).isCompatibleWith("text/*").isNotNull(); + } + + private MockHttpServletResponse createResponse(String contentType) { + MockHttpServletResponse response = new MockHttpServletResponse(); + response.setContentType(contentType); + return response; + } + } + + @Nested + class StatusTests { + + @Test + void hasStatusWithCode() { + assertThat(createResponse(200)).hasStatus(200); + } + + @Test + void hasStatusWithHttpStatus() { + assertThat(createResponse(200)).hasStatus(HttpStatus.OK); + } + + @Test + void hasStatusOK() { + assertThat(createResponse(200)).hasStatusOk(); + } + + @Test + void hasStatusWithWrongCode() { + MockHttpServletResponse response = createResponse(200); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(response).hasStatus(300)) + .withMessageContainingAll("HTTP status code", "200", "300"); + } + + @Test + void hasStatus1xxInformational() { + assertThat(createResponse(199)).hasStatus1xxInformational(); + } + + @Test + void hasStatus2xxSuccessful() { + assertThat(createResponse(299)).hasStatus2xxSuccessful(); + } + + @Test + void hasStatus3xxRedirection() { + assertThat(createResponse(399)).hasStatus3xxRedirection(); + } + + @Test + void hasStatus4xxClientError() { + assertThat(createResponse(499)).hasStatus4xxClientError(); + } + + @Test + void hasStatus5xxServerError() { + assertThat(createResponse(599)).hasStatus5xxServerError(); + } + + @Test + void hasStatusWithWrongSeries() { + MockHttpServletResponse response = createResponse(500); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(response).hasStatus2xxSuccessful()) + .withMessageContainingAll("HTTP status series", "SUCCESSFUL", "SERVER_ERROR"); + } + + private MockHttpServletResponse createResponse(int status) { + MockHttpServletResponse response = new MockHttpServletResponse(); + response.setStatus(status); + return response; + } + } + + + private static ResponseAssert assertThat(HttpServletResponse response) { + return new ResponseAssert(response); + } + + + private static final class ResponseAssert extends AbstractHttpServletResponseAssert { + + ResponseAssert(HttpServletResponse actual) { + super(actual, ResponseAssert.class); + } + + @Override + protected HttpServletResponse getResponse() { + return this.actual; + } + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssertTests.java new file mode 100644 index 000000000000..d1c50876601c --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletRequestAssertTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * Tests for {@link AbstractMockHttpServletRequestAssert}. + * + * @author Stephane Nicoll + */ +class AbstractMockHttpServletRequestAssertTests { + + @Test + void requestCanBeAsserted() { + MockHttpServletRequest request = new MockHttpServletRequest(); + assertThat(request).satisfies(actual -> assertThat(actual).isSameAs(request)); + } + + + private static RequestAssert assertThat(MockHttpServletRequest request) { + return new RequestAssert(request); + } + + private static final class RequestAssert extends AbstractMockHttpServletRequestAssert { + + RequestAssert(MockHttpServletRequest actual) { + super(actual, RequestAssert.class); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssertTests.java new file mode 100644 index 000000000000..c93ffce7ebb2 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AbstractMockHttpServletResponseAssertTests.java @@ -0,0 +1,151 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; + +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.json.JsonContent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link AbstractMockHttpServletResponseAssert}. + * + * @author Stephane Nicoll + */ +public class AbstractMockHttpServletResponseAssertTests { + + @Test + void bodyText() { + MockHttpServletResponse response = createResponse("OK"); + assertThat(fromResponse(response)).bodyText().isEqualTo("OK"); + } + + @Test + void bodyJsonWithJsonPath() { + MockHttpServletResponse response = createResponse("{\"albumById\": {\"name\": \"Greatest hits\"}}"); + assertThat(fromResponse(response)).bodyJson() + .extractingPath("$.albumById.name").isEqualTo("Greatest hits"); + } + + @Test + void bodyJsonCanLoadResourceRelativeToClass() { + MockHttpServletResponse response = createResponse("{ \"name\" : \"Spring\", \"age\" : 123 }"); + // See org/springframework/test/json/example.json + assertThat(fromResponse(response)).bodyJson().withResourceLoadClass(JsonContent.class) + .isLenientlyEqualTo("example.json"); + } + + @Test + void bodyWithByteArray() throws UnsupportedEncodingException { + byte[] bytes = "OK".getBytes(StandardCharsets.UTF_8); + MockHttpServletResponse response = new MockHttpServletResponse(); + response.getWriter().write("OK"); + response.setContentType(StandardCharsets.UTF_8.name()); + assertThat(fromResponse(response)).body().isEqualTo(bytes); + } + + @Test + void hasBodyTextEqualTo() throws UnsupportedEncodingException { + MockHttpServletResponse response = new MockHttpServletResponse(); + response.getWriter().write("OK"); + response.setContentType(StandardCharsets.UTF_8.name()); + assertThat(fromResponse(response)).hasBodyTextEqualTo("OK"); + } + + @Test + void hasForwardedUrl() { + String forwardedUrl = "https://example.com/42"; + MockHttpServletResponse response = new MockHttpServletResponse(); + response.setForwardedUrl(forwardedUrl); + assertThat(fromResponse(response)).hasForwardedUrl(forwardedUrl); + } + + @Test + void hasForwardedUrlWithWrongValue() { + String forwardedUrl = "https://example.com/42"; + MockHttpServletResponse response = new MockHttpServletResponse(); + response.setForwardedUrl(forwardedUrl); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(fromResponse(response)).hasForwardedUrl("another")) + .withMessageContainingAll("Forwarded URL", forwardedUrl, "another"); + } + + @Test + void hasRedirectedUrl() { + String redirectedUrl = "https://example.com/42"; + MockHttpServletResponse response = new MockHttpServletResponse(); + response.addHeader(HttpHeaders.LOCATION, redirectedUrl); + assertThat(fromResponse(response)).hasRedirectedUrl(redirectedUrl); + } + + @Test + void hasRedirectedUrlWithWrongValue() { + String redirectedUrl = "https://example.com/42"; + MockHttpServletResponse response = new MockHttpServletResponse(); + response.addHeader(HttpHeaders.LOCATION, redirectedUrl); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(fromResponse(response)).hasRedirectedUrl("another")) + .withMessageContainingAll("Redirected URL", redirectedUrl, "another"); + } + + @Test + void hasServletErrorMessage() throws Exception{ + MockHttpServletResponse response = new MockHttpServletResponse(); + response.sendError(403, "expected error message"); + assertThat(fromResponse(response)).hasErrorMessage("expected error message"); + } + + + private MockHttpServletResponse createResponse(String body) { + try { + MockHttpServletResponse response = new MockHttpServletResponse(); + response.setContentType(StandardCharsets.UTF_8.name()); + response.getWriter().write(body); + return response; + } + catch (UnsupportedEncodingException ex) { + throw new IllegalStateException(ex); + } + } + + private static AssertProvider fromResponse(MockHttpServletResponse response) { + return () -> new ResponseAssert(response); + } + + + private static final class ResponseAssert extends AbstractMockHttpServletResponseAssert { + + ResponseAssert(MockHttpServletResponse actual) { + super(null, actual, ResponseAssert.class); + } + + @Override + protected MockHttpServletResponse getResponse() { + return this.actual; + } + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/CookieMapAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/CookieMapAssertTests.java new file mode 100644 index 000000000000..4a00c438ac06 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/CookieMapAssertTests.java @@ -0,0 +1,181 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.time.Duration; +import java.util.List; + +import jakarta.servlet.http.Cookie; +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link CookieMapAssert}. + * + * @author Brian Clozel + */ +class CookieMapAssertTests { + + static Cookie[] cookies; + + @BeforeAll + static void setup() { + Cookie framework = new Cookie("framework", "spring"); + framework.setSecure(true); + framework.setHttpOnly(true); + Cookie age = new Cookie("age", "value"); + age.setMaxAge(1200); + Cookie domain = new Cookie("domain", "value"); + domain.setDomain("spring.io"); + Cookie path = new Cookie("path", "value"); + path.setPath("/spring"); + cookies = List.of(framework, age, domain, path).toArray(new Cookie[0]); + } + + @Test + void containsCookieWhenCookieExistsShouldPass() { + assertThat(cookies()).containsCookie("framework"); + } + + @Test + void containsCookieWhenCookieMissingShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(cookies()).containsCookie("missing")); + } + + @Test + void containsCookiesWhenCookiesExistShouldPass() { + assertThat(cookies()).containsCookies("framework", "age"); + } + + @Test + void containsCookiesWhenCookieMissingShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(cookies()).containsCookies("framework", "missing")); + } + + @Test + void doesNotContainCookieWhenCookieMissingShouldPass() { + assertThat(cookies()).doesNotContainCookie("missing"); + } + + @Test + void doesNotContainCookieWhenCookieExistsShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(cookies()).doesNotContainCookie("framework")); + } + + @Test + void doesNotContainCookiesWhenCookiesMissingShouldPass() { + assertThat(cookies()).doesNotContainCookies("missing", "missing2"); + } + + @Test + void doesNotContainCookiesWhenAtLeastOneCookieExistShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(cookies()).doesNotContainCookies("missing", "framework")); + } + + @Test + void hasValueEqualsWhenCookieValueMatchesShouldPass() { + assertThat(cookies()).hasValue("framework", "spring"); + } + + @Test + void hasValueEqualsWhenCookieValueDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(cookies()).hasValue("framework", "other")); + } + + @Test + void hasCookieSatisfyingWhenCookieValueMatchesShouldPass() { + assertThat(cookies()).hasCookieSatisfying("framework", cookie -> + assertThat(cookie.getValue()).startsWith("spr")); + } + + @Test + void hasCookieSatisfyingWhenCookieValueDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(cookies()).hasCookieSatisfying("framework", cookie -> + assertThat(cookie.getValue()).startsWith("not"))); + } + + @Test + void hasMaxAgeWhenCookieAgeMatchesShouldPass() { + assertThat(cookies()).hasMaxAge("age", Duration.ofMinutes(20)); + } + + @Test + void hasMaxAgeWhenCookieAgeDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(cookies()).hasMaxAge("age", Duration.ofMinutes(30))); + } + + @Test + void pathWhenCookiePathMatchesShouldPass() { + assertThat(cookies()).hasPath("path", "/spring"); + } + + @Test + void pathWhenCookiePathDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(cookies()).hasPath("path", "/other")); + } + + @Test + void hasDomainWhenCookieDomainMatchesShouldPass() { + assertThat(cookies()).hasDomain("domain", "spring.io"); + } + + @Test + void hasDomainWhenCookieDomainDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(cookies()).hasDomain("domain", "example.org")); + } + + @Test + void isSecureWhenCookieSecureMatchesShouldPass() { + assertThat(cookies()).isSecure("framework", true); + } + + @Test + void isSecureWhenCookieSecureDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(cookies()).isSecure("domain", true)); + } + + @Test + void isHttpOnlyWhenCookieHttpOnlyMatchesShouldPass() { + assertThat(cookies()).isHttpOnly("framework", true); + } + + @Test + void isHttpOnlyWhenCookieHttpOnlyDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertThat(cookies()).isHttpOnly("domain", true)); + } + + + private static AssertProvider cookies() { + return () -> new CookieMapAssert(cookies); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/DefaultMvcTestResultTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/DefaultMvcTestResultTests.java new file mode 100644 index 000000000000..c8bfa1d89f5f --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/DefaultMvcTestResultTests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.web.servlet.MvcResult; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link DefaultMvcTestResult}. + * + * @author Stephane Nicoll + */ +class DefaultMvcTestResultTests { + + @Test + void createWithMvcResultDelegatesToIt() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MvcResult mvcResult = mock(MvcResult.class); + given(mvcResult.getRequest()).willReturn(request); + DefaultMvcTestResult result = new DefaultMvcTestResult(mvcResult, null, null); + assertThat(result.getRequest()).isSameAs(request); + verify(mvcResult).getRequest(); + } + + @Test + void createWithExceptionDoesNotAllowAccessToRequest() { + assertRequestFailed(DefaultMvcTestResult::getRequest); + } + + @Test + void createWithExceptionDoesNotAllowAccessToResponse() { + assertRequestFailed(DefaultMvcTestResult::getResponse); + } + + @Test + void createWithExceptionDoesNotAllowAccessToResolvedException() { + assertRequestFailed(DefaultMvcTestResult::getResolvedException); + } + + @Test + void createWithExceptionReturnsException() { + IllegalStateException exception = new IllegalStateException("Expected"); + DefaultMvcTestResult result = new DefaultMvcTestResult(null, exception, null); + assertThat(result.getUnresolvedException()).isSameAs(exception); + } + + private void assertRequestFailed(Consumer action) { + DefaultMvcTestResult result = new DefaultMvcTestResult(null, new IllegalStateException("Expected"), null); + assertThatIllegalStateException() + .isThrownBy(() -> action.accept(result)) + .withMessageContaining("Request failed with unresolved exception"); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/HandlerResultAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/HandlerResultAssertTests.java new file mode 100644 index 000000000000..ea9e483c7b38 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/HandlerResultAssertTests.java @@ -0,0 +1,140 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.lang.reflect.Method; + +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.Test; + +import org.springframework.http.ResponseEntity; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.resource.DefaultServletHttpRequestHandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link HandlerResultAssert}. + * + * @author Stephane Nicoll + */ +class HandlerResultAssertTests { + + @Test + void hasTypeUseController() { + assertThat(handlerMethod(new TestController(), "greet")).hasType(TestController.class); + } + + @Test + void isMethodHandlerWithMethodHandler() { + assertThat(handlerMethod(new TestController(), "greet")).isMethodHandler(); + } + + @Test + void isMethodHandlerWithServletHandler() { + AssertProvider actual = handler(new DefaultServletHttpRequestHandler()); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).isMethodHandler()) + .withMessageContainingAll(DefaultServletHttpRequestHandler.class.getName(), + HandlerMethod.class.getName()); + } + + @Test + void methodName() { + assertThat(handlerMethod(new TestController(), "greet")).method().hasName("greet"); + } + + @Test + void declaringClass() { + assertThat(handlerMethod(new TestController(), "greet")).method().hasDeclaringClass(TestController.class); + } + + @Test + void method() { + assertThat(handlerMethod(new TestController(), "greet")).method().isEqualTo(method(TestController.class, "greet")); + } + + @Test + void methodWithServletHandler() { + AssertProvider actual = handler(new DefaultServletHttpRequestHandler()); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).method()) + .withMessageContainingAll(DefaultServletHttpRequestHandler.class.getName(), + HandlerMethod.class.getName()); + } + + @Test + void isInvokedOn() { + assertThat(handlerMethod(new TestController(), "greet")) + .isInvokedOn(TestController.class, TestController::greet); + } + + @Test + void isInvokedOnWithVoidMethod() { + assertThat(handlerMethod(new TestController(), "update")) + .isInvokedOn(TestController.class, controller -> { + controller.update(); + return controller; + }); + } + + @Test + void isInvokedOnWithWrongMethod() { + AssertProvider actual = handlerMethod(new TestController(), "update"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).isInvokedOn(TestController.class, TestController::greet)) + .withMessageContainingAll( + method(TestController.class, "greet").toGenericString(), + method(TestController.class, "update").toGenericString()); + } + + + private static AssertProvider handler(Object instance) { + return () -> new HandlerResultAssert(instance); + } + + private static AssertProvider handlerMethod(Object instance, String name, Class... parameterTypes) { + HandlerMethod handlerMethod = new HandlerMethod(instance, method(instance.getClass(), name, parameterTypes)); + return () -> new HandlerResultAssert(handlerMethod); + } + + private static Method method(Class target, String name, Class... parameterTypes) { + Method method = ReflectionUtils.findMethod(target, name, parameterTypes); + assertThat(method).isNotNull(); + return method; + } + + @RestController + static class TestController { + + @GetMapping("/greet") + ResponseEntity greet() { + return ResponseEntity.ok().body("Hello"); + } + + @PostMapping("/update") + void update() { + } + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/MockMvcTesterCompatibilityIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/MockMvcTesterCompatibilityIntegrationTests.java new file mode 100644 index 000000000000..712b725d90f8 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/MockMvcTesterCompatibilityIntegrationTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for {@link MockMvcTester} that use the methods that + * integrate with {@link MockMvc} way of building the requests and + * asserting the responses. + * + * @author Stephane Nicoll + */ +@SpringJUnitConfig +@WebAppConfiguration +class MockMvcTesterCompatibilityIntegrationTests { + + private final MockMvcTester mvc; + + MockMvcTesterCompatibilityIntegrationTests(@Autowired WebApplicationContext wac) { + this.mvc = MockMvcTester.from(wac); + } + + @Test + void performGet() { + assertThat(this.mvc.perform(get("/greet"))).hasStatusOk(); + } + + @Test + void performGetWithInvalidMediaTypeAssertion() { + MvcTestResult result = this.mvc.perform(get("/greet")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(result).hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .withMessageContaining("is compatible with 'application/json'"); + } + + @Test + void assertHttpStatusCode() { + assertThat(this.mvc.get().uri("/greet")).matches(status().isOk()); + } + + + @Configuration + @EnableWebMvc + @Import(TestController.class) + static class WebConfiguration { + } + + @RestController + static class TestController { + + @GetMapping(path = "/greet", produces = "text/plain") + String greet() { + return "hello"; + } + + @GetMapping(path = "/message", produces = MediaType.APPLICATION_JSON_VALUE) + String message() { + return "{\"message\": \"hello\"}"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/MockMvcTesterIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/MockMvcTesterIntegrationTests.java new file mode 100644 index 000000000000..2c0a8d45ad48 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/MockMvcTesterIntegrationTests.java @@ -0,0 +1,818 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.Part; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; +import org.assertj.core.api.InstanceOfAssertFactories; +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.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.mock.web.MockPart; +import org.springframework.stereotype.Controller; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.Person; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.assertj.MockMvcTester.MockMultipartMvcRequestBuilder; +import org.springframework.test.web.servlet.assertj.MockMvcTester.MockMvcRequestBuilder; +import org.springframework.ui.Model; +import org.springframework.validation.Errors; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.SessionAttributes; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import static java.util.Map.entry; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Integration tests for {@link MockMvcTester}. + * + * @author Brian Clozel + * @author Stephane Nicoll + */ +@SpringJUnitWebConfig +public class MockMvcTesterIntegrationTests { + + private static final MockMultipartFile file = new MockMultipartFile("file", "content.txt", null, + "value".getBytes(StandardCharsets.UTF_8)); + + + private final MockMvcTester mvc; + + MockMvcTesterIntegrationTests(WebApplicationContext wac) { + this.mvc = MockMvcTester.from(wac); + } + + @Nested + class PerformTests { + + @Test + void syncRequestWithDefaultExchange() { + assertThat(mvc.get().uri("/greet")).hasStatusOk(); + } + + @Test + void asyncRequestWithDefaultExchange() { + assertThat(mvc.get().uri("/streaming").param("timeToWait", "100")).hasStatusOk() + .hasBodyTextEqualTo("name=Joe&someBoolean=true"); + } + + @Test + void asyncMultipartRequestWithDefaultExchange() { + assertThat(mvc.post().uri("/multipart-streaming").multipart() + .file(file).param("timeToWait", "100")) + .hasStatusOk().hasBodyTextEqualTo("name=Joe&file=content.txt"); + } + + @Test + void syncRequestWithExplicitExchange() { + assertThat(mvc.get().uri("/greet").exchange()).hasStatusOk(); + } + + @Test + void asyncRequestWithExplicitExchange() { + assertThat(mvc.get().uri("/streaming").param("timeToWait", "100").exchange()) + .hasStatusOk().hasBodyTextEqualTo("name=Joe&someBoolean=true"); + } + + @Test + void asyncMultipartRequestWitExplicitExchange() { + assertThat(mvc.post().uri("/multipart-streaming").multipart() + .file(file).param("timeToWait", "100").exchange()) + .hasStatusOk().hasBodyTextEqualTo("name=Joe&file=content.txt"); + } + + @Test + void syncRequestWithExplicitExchangeIgnoresDuration() { + Duration timeToWait = mock(Duration.class); + assertThat(mvc.get().uri("/greet").exchange(timeToWait)).hasStatusOk(); + verifyNoInteractions(timeToWait); + } + + @Test + void asyncRequestWithExplicitExchangeAndEnoughTimeToWait() { + assertThat(mvc.get().uri("/streaming").param("timeToWait", "100").exchange(Duration.ofMillis(200))) + .hasStatusOk().hasBodyTextEqualTo("name=Joe&someBoolean=true"); + } + + @Test + void asyncMultipartRequestWithExplicitExchangeAndEnoughTimeToWait() { + assertThat(mvc.post().uri("/multipart-streaming").multipart() + .file(file).param("timeToWait", "100").exchange(Duration.ofMillis(200))) + .hasStatusOk().hasBodyTextEqualTo("name=Joe&file=content.txt"); + } + + @Test + void asyncRequestWithExplicitExchangeAndNotEnoughTimeToWait() { + MockMvcRequestBuilder builder = mvc.get().uri("/streaming").param("timeToWait", "500"); + assertThatIllegalStateException() + .isThrownBy(() -> builder.exchange(Duration.ofMillis(100))) + .withMessageContaining("was not set during the specified timeToWait=100"); + } + + @Test + void asyncMultipartRequestWithExplicitExchangeAndNotEnoughTimeToWait() { + MockMultipartMvcRequestBuilder builder = mvc.post().uri("/multipart-streaming").multipart() + .file(file).param("timeToWait", "500"); + assertThatIllegalStateException() + .isThrownBy(() -> builder.exchange(Duration.ofMillis(100))) + .withMessageContaining("was not set during the specified timeToWait=100"); + } + } + + @Nested + class RequestTests { + + @Test + void hasAsyncStartedTrue() { + assertThat(mvc.get().uri("/callable").accept(MediaType.APPLICATION_JSON).asyncExchange()) + .request().hasAsyncStarted(true); + } + + @Test + void hasAsyncStartedForMultipartTrue() { + assertThat(mvc.post().uri("/multipart-streaming").multipart() + .file(file).param("timeToWait", "100").asyncExchange()) + .request().hasAsyncStarted(true); + } + + @Test + void hasAsyncStartedFalse() { + assertThat(mvc.get().uri("/greet").asyncExchange()).request().hasAsyncStarted(false); + } + + @Test + void hasAsyncStartedForMultipartFalse() { + assertThat(mvc.put().uri("/multipart-put").multipart().file(file).asyncExchange()) + .request().hasAsyncStarted(false); + } + + @Test + void attributes() { + assertThat(mvc.get().uri("/greet")).request().attributes() + .containsKey(DispatcherServlet.WEB_APPLICATION_CONTEXT_ATTRIBUTE); + } + + @Test + void sessionAttributes() { + assertThat(mvc.get().uri("/locale")).request().sessionAttributes() + .containsOnly(entry("locale", Locale.UK)); + } + } + + @Nested + class MultipartTests { + + private final MockMultipartFile JSON_PART_FILE = new MockMultipartFile("json", "json", "application/json", """ + { + "name": "test" + }""".getBytes(StandardCharsets.UTF_8)); + + @Test + void multipartSetsContentType() { + assertThat(mvc.put().uri("/multipart-put").multipart().file(file).file(JSON_PART_FILE)) + .request().satisfies(request -> assertThat(request.getContentType()) + .isEqualTo(MediaType.MULTIPART_FORM_DATA_VALUE)); + } + + @Test + void multipartWithPut() { + assertThat(mvc.put().uri("/multipart-put").multipart().file(file).file(JSON_PART_FILE)) + .hasStatusOk() + .hasViewName("index") + .model().contains(entry("name", "file")); + } + + @Test + void multipartWithMissingPart() { + assertThat(mvc.put().uri("/multipart-put").multipart().file(JSON_PART_FILE)) + .hasStatus(HttpStatus.BAD_REQUEST) + .failure().isInstanceOfSatisfying(MissingServletRequestPartException.class, + ex -> assertThat(ex.getRequestPartName()).isEqualTo("file")); + } + + @Test + void multipartWithNamedPart() { + MockPart part = new MockPart("part", "content.txt", "value".getBytes(StandardCharsets.UTF_8)); + assertThat(mvc.post().uri("/part").multipart().part(part).file(JSON_PART_FILE)) + .hasStatusOk() + .hasViewName("index") + .model().contains(entry("part", "content.txt"), entry("name", "test")); + } + } + + @Nested + class CookieTests { + + @Test + void containsCookie() { + Cookie cookie = new Cookie("test", "value"); + assertThat(withCookie(cookie).get().uri("/greet")).cookies().containsCookie("test"); + } + + @Test + void hasValue() { + Cookie cookie = new Cookie("test", "value"); + assertThat(withCookie(cookie).get().uri("/greet")).cookies().hasValue("test", "value"); + } + + private MockMvcTester withCookie(Cookie cookie) { + return MockMvcTester.of(List.of(new TestController()), builder -> builder.addInterceptors( + new HandlerInterceptor() { + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + response.addCookie(cookie); + return true; + } + }).build()); + } + } + + @Nested + class StatusTests { + + @Test + void statusOk() { + assertThat(mvc.get().uri("/greet")).hasStatusOk(); + } + + @Test + void statusSeries() { + assertThat(mvc.get().uri("/greet")).hasStatus2xxSuccessful(); + } + } + + @Nested + class HeadersTests { + + @Test + void shouldAssertHeader() { + assertThat(mvc.get().uri("/greet")) + .hasHeader("Content-Type", "text/plain;charset=ISO-8859-1"); + } + + @Test + void shouldAssertHeaderWithCallback() { + assertThat(mvc.get().uri("/greet")).headers().satisfies(textContent("ISO-8859-1")); + } + + private Consumer textContent(String charset) { + return headers -> assertThat(headers).containsEntry( + "Content-Type", List.of("text/plain;charset=%s".formatted(charset))); + } + } + + @Nested + class ModelAndViewTests { + + @Test + void hasViewName() { + assertThat(mvc.get().uri("/persons/{0}", "Andy")).hasViewName("persons/index"); + } + + @Test + void viewNameWithCustomAssertion() { + assertThat(mvc.get().uri("/persons/{0}", "Andy")).viewName().startsWith("persons"); + } + + @Test + void containsAttributes() { + assertThat(mvc.post().uri("/persons").param("name", "Andy")).model() + .containsOnlyKeys("name").containsEntry("name", "Andy"); + } + + @Test + void hasErrors() { + assertThat(mvc.post().uri("/persons")).model().hasErrors(); + } + + @Test + void hasAttributeErrors() { + assertThat(mvc.post().uri("/persons")).model().hasAttributeErrors("person"); + } + + @Test + void hasAttributeErrorsCount() { + assertThat(mvc.post().uri("/persons")).model().extractingBindingResult("person").hasErrorsCount(1); + } + } + + @Nested + class FlashTests { + + @Test + void containsAttributes() { + assertThat(mvc.post().uri("/persons").param("name", "Andy")).flash() + .containsOnlyKeys("message").hasEntrySatisfying("message", + value -> assertThat(value).isInstanceOfSatisfying(String.class, + stringValue -> assertThat(stringValue).startsWith("success"))); + } + } + + @Nested + class BodyTests { + + @Test + void asyncResult() { + MvcTestResult result = mvc.get().uri("/callable").accept(MediaType.APPLICATION_JSON).asyncExchange(); + assertThat(result.getMvcResult().getAsyncResult()) + .asInstanceOf(InstanceOfAssertFactories.map(String.class, Object.class)) + .containsOnly(entry("key", "value")); + } + + @Test + void stringContent() { + assertThat(mvc.get().uri("/greet")).body().asString().isEqualTo("hello"); + } + + @Test + void jsonPathContent() { + assertThat(mvc.get().uri("/message")).bodyJson() + .extractingPath("$.message").asString().isEqualTo("hello"); + } + + @Test + void jsonContentCanLoadResourceFromClasspath() { + assertThat(mvc.get().uri("/message")).bodyJson().isLenientlyEqualTo( + new ClassPathResource("message.json", MockMvcTesterIntegrationTests.class)); + } + + @Test + void jsonContentUsingResourceLoaderClass() { + assertThat(mvc.get().uri("/message")).bodyJson().withResourceLoadClass(MockMvcTesterIntegrationTests.class) + .isLenientlyEqualTo("message.json"); + } + } + + @Nested + class HandlerTests { + + @Test + void handlerOn404() { + assertThat(mvc.get().uri("/unknown-resource")).handler().isNull(); + } + + @Test + void hasType() { + assertThat(mvc.get().uri("/greet")).handler().hasType(TestController.class); + } + + @Test + void isMethodHandler() { + assertThat(mvc.get().uri("/greet")).handler().isMethodHandler(); + } + + @Test + void isInvokedOn() { + assertThat(mvc.get().uri("/callable")).handler() + .isInvokedOn(AsyncController.class, AsyncController::getCallable); + } + } + + @Nested + class DebugTests { + + private final PrintStream standardOut = System.out; + + private final ByteArrayOutputStream capturedOut = new ByteArrayOutputStream(); + + @BeforeEach + public void setUp() { + System.setOut(new PrintStream(capturedOut)); + } + + @AfterEach + public void tearDown() { + System.setOut(standardOut); + } + + @Test + void debugUsesSystemOutByDefault() { + assertThat(mvc.get().uri("/greet")).debug().hasStatusOk(); + assertThat(capturedOut()).contains("MockHttpServletRequest:", "MockHttpServletResponse:"); + } + + @Test + void debugCanPrintToCustomOutputStream() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThat(mvc.get().uri("/greet")).debug(out).hasStatusOk(); + assertThat(out.toString(StandardCharsets.UTF_8)) + .contains("MockHttpServletRequest:", "MockHttpServletResponse:"); + assertThat(capturedOut()).isEmpty(); + } + + @Test + void debugCanPrintToCustomWriter() { + StringWriter out = new StringWriter(); + assertThat(mvc.get().uri("/greet")).debug(out).hasStatusOk(); + assertThat(out.toString()) + .contains("MockHttpServletRequest:", "MockHttpServletResponse:"); + assertThat(capturedOut()).isEmpty(); + } + + private String capturedOut() { + return this.capturedOut.toString(StandardCharsets.UTF_8); + } + + } + + @Nested + class ExceptionTests { + + @Test + void hasFailedWithUnresolvedException() { + assertThat(mvc.get().uri("/error/1")).hasFailed(); + } + + @Test + void hasFailedWithResolvedException() { + assertThat(mvc.get().uri("/error/2")).hasFailed().hasStatus(HttpStatus.PAYMENT_REQUIRED); + } + + @Test + void doesNotHaveFailedWithoutException() { + assertThat(mvc.get().uri("/greet")).doesNotHaveFailed(); + } + + @Test + void doesNotHaveFailedWithUnresolvedException() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mvc.get().uri("/error/1")).doesNotHaveFailed()) + .withMessage("Expected request to succeed, but it failed"); + } + + @Test + void doesNotHaveFailedWithResolvedException() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mvc.get().uri("/error/2")).doesNotHaveFailed()) + .withMessage("Expected request to succeed, but it failed"); + } + + @Test + void hasFailedWithoutException() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mvc.get().uri("/greet")).hasFailed()) + .withMessage("Expected request to fail, but it succeeded"); + } + + @Test + void failureWithUnresolvedException() { + assertThat(mvc.get().uri("/error/1")).failure() + .isInstanceOf(ServletException.class) + .cause().isInstanceOf(IllegalStateException.class).hasMessage("Expected"); + } + + @Test + void failureWithResolvedException() { + assertThat(mvc.get().uri("/error/2")).failure() + .isInstanceOfSatisfying(ResponseStatusException.class, ex -> + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.PAYMENT_REQUIRED)); + } + + @Test + void failureWithoutException() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mvc.get().uri("/greet")).failure()) + .withMessage("Expected request to fail, but it succeeded"); + } + + // Check that assertions fail immediately if request failed with unresolved exception + + @Test + void assertAndApplyWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).apply(mvcResult -> {})); + } + + @Test + void assertContentTypeWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).contentType()); + } + + @Test + void assertCookiesWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).cookies()); + } + + @Test + void assertFlashWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).flash()); + } + + @Test + void assertStatusWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).hasStatus(3)); + } + + @Test + void assertHeaderWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).headers()); + } + + @Test + void assertViewNameWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).hasViewName("test")); + } + + @Test + void assertForwardedUrlWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).hasForwardedUrl("test")); + } + + @Test + void assertRedirectedUrlWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).hasRedirectedUrl("test")); + } + + @Test + void assertErrorMessageWithUnresolvedException() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mvc.get().uri("/error/message")).hasErrorMessage("invalid")) + .withMessageContainingAll("[Servlet error message]", "invalid", "expected error message"); + } + + @Test + void assertRequestWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).request()); + } + + @Test + void assertModelWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).model()); + } + + @Test + void assertBodyWithUnresolvedException() { + testAssertionFailureWithUnresolvableException( + result -> assertThat(result).body()); + } + + + private void testAssertionFailureWithUnresolvableException(Consumer assertions) { + MvcTestResult result = mvc.get().uri("/error/1").exchange(); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.accept(result)) + .withMessageContainingAll("Request failed unexpectedly:", + ServletException.class.getName(), IllegalStateException.class.getName(), + "Expected"); + } + } + + @Test + void hasForwardUrl() { + assertThat(mvc.get().uri("/persons/John")).hasForwardedUrl("persons/index"); + } + + @Test + void hasRedirectUrl() { + assertThat(mvc.post().uri("/persons").param("name", "Andy")).hasStatus(HttpStatus.FOUND) + .hasRedirectedUrl("/persons/Andy"); + } + + @Test + void satisfiesAllowsAdditionalAssertions() { + assertThat(mvc.get().uri("/greet")).satisfies(result -> { + assertThat(result).isInstanceOf(MvcTestResult.class); + assertThat(result).hasStatusOk(); + }); + } + + @Test + void resultMatcherCanBeReused() throws Exception { + MvcTestResult result = mvc.get().uri("/greet").exchange(); + ResultMatcher matcher = mock(ResultMatcher.class); + assertThat(result).matches(matcher); + verify(matcher).match(result.getMvcResult()); + } + + @Test + void resultMatcherFailsWithDedicatedException() { + ResultMatcher matcher = result -> assertThat(result.getResponse().getStatus()) + .isEqualTo(HttpStatus.NOT_FOUND.value()); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(mvc.get().uri("/greet")).matches(matcher)) + .withMessageContaining("expected: 404").withMessageContaining(" but was: 200"); + } + + @Test + void shouldApplyResultHandler() { // Spring RESTDocs example + AtomicBoolean applied = new AtomicBoolean(); + assertThat(mvc.get().uri("/greet")).apply(result -> applied.set(true)); + assertThat(applied).isTrue(); + } + + + @Configuration + @EnableWebMvc + @Import({ TestController.class, PersonController.class, AsyncController.class, + MultipartController.class, SessionController.class, ErrorController.class }) + static class WebConfiguration { + } + + @RestController + static class TestController { + + @GetMapping(path = "/greet", produces = "text/plain") + String greet() { + return "hello"; + } + + @GetMapping(path = "/message", produces = MediaType.APPLICATION_JSON_VALUE) + String message() { + return "{\"message\": \"hello\"}"; + } + } + + @Controller + @RequestMapping("/persons") + static class PersonController { + + @GetMapping("/{name}") + public String get(@PathVariable String name, Model model) { + model.addAttribute(new Person(name)); + return "persons/index"; + } + + @PostMapping + String create(@Valid Person person, Errors errors, RedirectAttributes redirectAttrs) { + if (errors.hasErrors()) { + return "persons/add"; + } + redirectAttrs.addAttribute("name", person.getName()); + redirectAttrs.addFlashAttribute("message", "success!"); + return "redirect:/persons/{name}"; + } + } + + @RestController + static class AsyncController { + + @GetMapping("/callable") + public Callable> getCallable() { + return () -> Collections.singletonMap("key", "value"); + } + + @GetMapping("/streaming") + StreamingResponseBody streaming(@RequestParam long timeToWait) { + return out -> { + PrintStream stream = new PrintStream(out, true, StandardCharsets.UTF_8); + stream.print("name=Joe"); + try { + Thread.sleep(timeToWait); + stream.print("&someBoolean=true"); + } + catch (InterruptedException e) { + /* no-op */ + } + }; + } + } + + @Controller + static class MultipartController { + + @PostMapping("/part") + ModelAndView part(@RequestPart Part part, @RequestPart Map json) { + Map model = new HashMap<>(json); + model.put(part.getName(), part.getSubmittedFileName()); + return new ModelAndView("index", model); + } + + @PutMapping("/multipart-put") + ModelAndView multiPartViaHttpPut(@RequestParam MultipartFile file) { + return new ModelAndView("index", Map.of("name", file.getName())); + } + + @PostMapping("/multipart-streaming") + StreamingResponseBody streaming(@RequestParam MultipartFile file, @RequestParam long timeToWait) { + return out -> { + PrintStream stream = new PrintStream(out, true, StandardCharsets.UTF_8); + stream.print("name=Joe"); + try { + Thread.sleep(timeToWait); + stream.print("&file=" + file.getOriginalFilename()); + } + catch (InterruptedException e) { + /* no-op */ + } + }; + } + } + + @Controller + @SessionAttributes("locale") + static class SessionController { + + @ModelAttribute + void populate(Model model) { + model.addAttribute("locale", Locale.UK); + } + + @RequestMapping("/locale") + String handle() { + return "view"; + } + } + + @Controller + static class ErrorController { + + @GetMapping("/error/1") + public String one() { + throw new IllegalStateException("Expected"); + } + + @GetMapping("/error/2") + public String two() { + throw new ResponseStatusException(HttpStatus.PAYMENT_REQUIRED); + } + + @GetMapping("/error/validation/{id}") + public String validation(@PathVariable @Size(max = 4) String id) { + return "Hello " + id; + } + + @GetMapping("/error/message") + @ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "expected error message") + public void errorMessage() { + + } + + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/MockMvcTesterTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/MockMvcTesterTests.java new file mode 100644 index 000000000000..1ca87ca3d425 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/MockMvcTesterTests.java @@ -0,0 +1,285 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.Test; + +import org.springframework.cglib.core.internal.Function; +import org.springframework.context.annotation.AnnotationConfigUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockServletContext; +import org.springframework.test.json.AbstractJsonContentAssert; +import org.springframework.test.web.servlet.assertj.MockMvcTester.MockMvcRequestBuilder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.support.GenericWebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** + * Tests for {@link MockMvcTester}. + * + * @author Stephane Nicoll + */ +class MockMvcTesterTests { + + private static final MappingJackson2HttpMessageConverter jsonHttpMessageConverter = + new MappingJackson2HttpMessageConverter(new ObjectMapper()); + + private final ServletContext servletContext = new MockServletContext(); + + + @Test + void createShouldRejectNullMockMvc() { + assertThatIllegalArgumentException().isThrownBy(() -> MockMvcTester.create(null)); + } + + @Test + void createWithExistingWebApplicationContext() { + try (GenericWebApplicationContext wac = create(WebConfiguration.class)) { + MockMvcTester mockMvc = MockMvcTester.from(wac); + assertThat(mockMvc.perform(post("/increase"))).hasBodyTextEqualTo("counter 41"); + assertThat(mockMvc.perform(post("/increase"))).hasBodyTextEqualTo("counter 42"); + } + } + + @Test + void createWithControllerClassShouldInstantiateControllers() { + MockMvcTester mockMvc = MockMvcTester.of(HelloController.class, CounterController.class); + assertThat(mockMvc.perform(get("/hello"))).hasBodyTextEqualTo("Hello World"); + assertThat(mockMvc.perform(post("/increase"))).hasBodyTextEqualTo("counter 1"); + assertThat(mockMvc.perform(post("/increase"))).hasBodyTextEqualTo("counter 2"); + } + + @Test + void createWithControllersShouldUseThemAsIs() { + MockMvcTester mockMvc = MockMvcTester.of(new HelloController(), + new CounterController(new AtomicInteger(41))); + assertThat(mockMvc.perform(get("/hello"))).hasBodyTextEqualTo("Hello World"); + assertThat(mockMvc.perform(post("/increase"))).hasBodyTextEqualTo("counter 42"); + assertThat(mockMvc.perform(post("/increase"))).hasBodyTextEqualTo("counter 43"); + } + + @Test + void createWithControllerAndCustomizations() { + MockMvcTester mockMvc = MockMvcTester.of(List.of(new HelloController()), builder -> + builder.defaultRequest(get("/hello").accept(MediaType.APPLICATION_JSON)).build()); + assertThat(mockMvc.perform(get("/hello"))).hasStatus(HttpStatus.NOT_ACCEPTABLE); + } + + @Test + void createWithControllersHasNoHttpMessageConverter() { + MockMvcTester mockMvc = MockMvcTester.of(new HelloController()); + AbstractJsonContentAssert jsonContentAssert = assertThat(mockMvc.perform(get("/json"))).hasStatusOk().bodyJson(); + assertThatIllegalStateException() + .isThrownBy(() -> jsonContentAssert.extractingPath("$").convertTo(Message.class)) + .withMessageContaining("No JSON message converter available"); + } + + @Test + void createWithControllerCanConfigureHttpMessageConverters() { + MockMvcTester mockMvc = MockMvcTester.of(HelloController.class) + .withHttpMessageConverters(List.of(jsonHttpMessageConverter)); + assertThat(mockMvc.perform(get("/json"))).hasStatusOk().bodyJson() + .extractingPath("$").convertTo(Message.class).satisfies(message -> { + assertThat(message.message()).isEqualTo("Hello World"); + assertThat(message.counter()).isEqualTo(42); + }); + } + + @Test + void withHttpMessageConverterUsesConverter() { + MappingJackson2HttpMessageConverter converter = spy(jsonHttpMessageConverter); + MockMvcTester mockMvc = MockMvcTester.of(HelloController.class) + .withHttpMessageConverters(List.of(mock(), mock(), converter)); + assertThat(mockMvc.perform(get("/json"))).hasStatusOk().bodyJson() + .extractingPath("$").convertTo(Message.class).satisfies(message -> { + assertThat(message.message()).isEqualTo("Hello World"); + assertThat(message.counter()).isEqualTo(42); + }); + verify(converter).canWrite(LinkedHashMap.class, LinkedHashMap.class, MediaType.APPLICATION_JSON); + } + + @Test + void performWithUnresolvedExceptionSetsException() { + MockMvcTester mockMvc = MockMvcTester.of(HelloController.class); + MvcTestResult result = mockMvc.perform(get("/error")); + assertThat(result.getUnresolvedException()).isInstanceOf(ServletException.class) + .cause().isInstanceOf(IllegalStateException.class).hasMessage("Expected"); + assertThat(result).hasFieldOrPropertyWithValue("mvcResult", null); + } + + @Test + void getConfiguresBuilder() { + assertThat(createMockHttpServletRequest(tester -> tester.get().uri("/hello/{id}", "world"))) + .satisfies(hasSettings(HttpMethod.GET, "/hello/{id}", "/hello/world")); + } + + @Test + void headConfiguresBuilder() { + assertThat(createMockHttpServletRequest(tester -> tester.head().uri("/download/{file}", "test.json"))) + .satisfies(hasSettings(HttpMethod.HEAD, "/download/{file}", "/download/test.json")); + } + + @Test + void postConfiguresBuilder() { + assertThat(createMockHttpServletRequest(tester -> tester.post().uri("/save/{id}", 123))) + .satisfies(hasSettings(HttpMethod.POST, "/save/{id}", "/save/123")); + } + + @Test + void putConfiguresBuilder() { + assertThat(createMockHttpServletRequest(tester -> tester.put().uri("/save/{id}", 123))) + .satisfies(hasSettings(HttpMethod.PUT, "/save/{id}", "/save/123")); + } + + @Test + void patchConfiguresBuilder() { + assertThat(createMockHttpServletRequest(tester -> tester.patch().uri("/update/{id}", 123))) + .satisfies(hasSettings(HttpMethod.PATCH, "/update/{id}", "/update/123")); + } + + @Test + void deleteConfiguresBuilder() { + assertThat(createMockHttpServletRequest(tester -> tester.delete().uri("/users/{id}", 42))) + .satisfies(hasSettings(HttpMethod.DELETE, "/users/{id}", "/users/42")); + } + + @Test + void optionsConfiguresBuilder() { + assertThat(createMockHttpServletRequest(tester -> tester.options().uri("/users/{id}", 42))) + .satisfies(hasSettings(HttpMethod.OPTIONS, "/users/{id}", "/users/42")); + } + + @Test + void methodConfiguresBuilderWithCustomMethod() { + HttpMethod customMethod = HttpMethod.valueOf("CUSTOM"); + assertThat(createMockHttpServletRequest(tester -> tester.method(customMethod).uri("/hello"))) + .satisfies(hasSettings(customMethod, "/hello", "/hello")); + } + + @Test + void methodConfiguresBuilderWithFullURI() { + assertThat(createMockHttpServletRequest(tester -> tester.get().uri(URI.create("/hello/world")))) + .satisfies(hasSettings(HttpMethod.GET, null, "/hello/world")); + } + + private MockHttpServletRequest createMockHttpServletRequest(Function builder) { + MockMvcTester mockMvcTester = MockMvcTester.of(HelloController.class); + return builder.apply(mockMvcTester).buildRequest(this.servletContext); + } + + private Consumer hasSettings(HttpMethod method, @Nullable String uriTemplate, String uri) { + return request -> { + assertThat(request.getMethod()).isEqualTo(method.name()); + assertThat(request.getUriTemplate()).isEqualTo(uriTemplate); + assertThat(request.getRequestURI()).isEqualTo(uri); + }; + } + + private GenericWebApplicationContext create(Class... classes) { + GenericWebApplicationContext applicationContext = new GenericWebApplicationContext(new MockServletContext()); + AnnotationConfigUtils.registerAnnotationConfigProcessors(applicationContext); + for (Class beanClass : classes) { + applicationContext.registerBean(beanClass); + } + applicationContext.refresh(); + return applicationContext; + } + + @Configuration(proxyBeanMethods = false) + @EnableWebMvc + static class WebConfiguration { + + @Bean + CounterController counterController() { + return new CounterController(new AtomicInteger(40)); + } + } + + + @RestController + private static class HelloController { + + @GetMapping(path = "/hello", produces = "text/plain") + public String hello() { + return "Hello World"; + } + + @GetMapping("/error") + public String error() { + throw new IllegalStateException("Expected"); + } + + @GetMapping(path = "/json", produces = "application/json") + public String json() { + return """ + { + "message": "Hello World", + "counter": 42 + }"""; + } + } + + private record Message(String message, int counter) {} + + @RestController + static class CounterController { + + private final AtomicInteger counter; + + CounterController() { + this(new AtomicInteger()); + } + + CounterController(AtomicInteger counter) { + this.counter = counter; + } + + @PostMapping("/increase") + String increase() { + int value = this.counter.incrementAndGet(); + return "counter " + value; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/ModelAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/ModelAssertTests.java new file mode 100644 index 000000000000..b5ac3b72cbaf --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/ModelAssertTests.java @@ -0,0 +1,178 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.assertj; + +import java.util.HashMap; +import java.util.Map; + +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.MutablePropertyValues; +import org.springframework.beans.testfixture.beans.TestBean; +import org.springframework.validation.BindException; +import org.springframework.validation.DataBinder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link ModelAssert}. + * + * @author Stephane Nicoll + */ +class ModelAssertTests { + + @Test + void hasErrors() { + assertThat(forModel(new TestBean(), Map.of("name", "John", "age", "4x"))).hasErrors(); + } + + @Test + void hasErrorsWithNoError() { + AssertProvider actual = forModel(new TestBean(), Map.of("name", "John", "age", "42")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).hasErrors()) + .withMessageContainingAll("John", "to have at least one error"); + } + + @Test + void doesNotHaveErrors() { + assertThat(forModel(new TestBean(), Map.of("name", "John", "age", "42"))).doesNotHaveErrors(); + } + + @Test + void doesNotHaveErrorsWithError() { + AssertProvider actual = forModel(new TestBean(), Map.of("name", "John", "age", "4x")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).doesNotHaveErrors()) + .withMessageContainingAll("John", "to not have an error, but got 1"); + } + + @Test + void extractBindingResultForAttributeInError() { + Map model = new HashMap<>(); + augmentModel(model, "person", new TestBean(), Map.of("name", "John", "age", "4x", "touchy", "invalid.value")); + assertThat(forModel(model)).extractingBindingResult("person").hasErrorsCount(2); + } + + @Test + void hasErrorCountForUnknownAttribute() { + Map model = new HashMap<>(); + augmentModel(model, "person", new TestBean(), Map.of("name", "John", "age", "42")); + AssertProvider actual = forModel(model); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).extractingBindingResult("user")) + .withMessageContainingAll("to have a binding result for attribute 'user'"); + } + + @Test + void hasErrorsWithMatchingAttributes() { + Map model = new HashMap<>(); + augmentModel(model, "wrong1", new TestBean(), Map.of("name", "first", "age", "4x")); + augmentModel(model, "valid", new TestBean(), Map.of("name", "second")); + augmentModel(model, "wrong2", new TestBean(), Map.of("name", "third", "touchy", "invalid.name")); + assertThat(forModel(model)).hasAttributeErrors("wrong1", "wrong2"); + } + + @Test + void hasErrorsWithOneNonMatchingAttribute() { + Map model = new HashMap<>(); + augmentModel(model, "wrong1", new TestBean(), Map.of("name", "first", "age", "4x")); + augmentModel(model, "valid", new TestBean(), Map.of("name", "second")); + augmentModel(model, "wrong2", new TestBean(), Map.of("name", "third", "touchy", "invalid.name")); + AssertProvider actual = forModel(model); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).hasAttributeErrors("wrong1", "valid")) + .withMessageContainingAll("to have attribute errors for:", "wrong1, valid", + "but these attributes do not have any errors:", "valid"); + } + + @Test + void hasErrorsWithOneNonMatchingAttributeAndOneUnknownAttribute() { + Map model = new HashMap<>(); + augmentModel(model, "wrong1", new TestBean(), Map.of("name", "first", "age", "4x")); + augmentModel(model, "valid", new TestBean(), Map.of("name", "second")); + augmentModel(model, "wrong2", new TestBean(), Map.of("name", "third", "touchy", "invalid.name")); + AssertProvider actual = forModel(model); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).hasAttributeErrors("wrong1", "unknown", "valid")) + .withMessageContainingAll("to have attribute errors for:", "wrong1, unknown, valid", + "but could not find these attributes:", "unknown", + "and these attributes do not have any errors:", "valid"); + } + + @Test + void doesNotHaveErrorsWithMatchingAttributes() { + Map model = new HashMap<>(); + augmentModel(model, "valid1", new TestBean(), Map.of("name", "first")); + augmentModel(model, "wrong", new TestBean(), Map.of("name", "second", "age", "4x")); + augmentModel(model, "valid2", new TestBean(), Map.of("name", "third")); + assertThat(forModel(model)).doesNotHaveAttributeErrors("valid1", "valid2"); + } + + @Test + void doesNotHaveErrorsWithOneNonMatchingAttribute() { + Map model = new HashMap<>(); + augmentModel(model, "valid1", new TestBean(), Map.of("name", "first")); + augmentModel(model, "wrong", new TestBean(), Map.of("name", "second", "age", "4x")); + augmentModel(model, "valid2", new TestBean(), Map.of("name", "third")); + AssertProvider actual = forModel(model); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).doesNotHaveAttributeErrors("valid1", "wrong")) + .withMessageContainingAll("to have attribute without errors for:", "valid1, wrong", + "but these attributes have at least one error:", "wrong"); + } + + @Test + void doesNotHaveErrorsWithOneNonMatchingAttributeAndOneUnknownAttribute() { + Map model = new HashMap<>(); + augmentModel(model, "valid1", new TestBean(), Map.of("name", "first")); + augmentModel(model, "wrong", new TestBean(), Map.of("name", "second", "age", "4x")); + augmentModel(model, "valid2", new TestBean(), Map.of("name", "third")); + AssertProvider actual = forModel(model); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(actual).doesNotHaveAttributeErrors("valid1", "unknown", "wrong")) + .withMessageContainingAll("to have attribute without errors for:", "valid1, unknown, wrong", + "but could not find these attributes:", "unknown", + "and these attributes have at least one error:", "wrong"); + } + + private AssertProvider forModel(Map model) { + return () -> new ModelAssert(model); + } + + private AssertProvider forModel(Object instance, Map propertyValues) { + Map model = new HashMap<>(); + augmentModel(model, "test", instance, propertyValues); + return forModel(model); + } + + private static void augmentModel(Map model, String attribute, Object instance, Map propertyValues) { + DataBinder binder = new DataBinder(instance, attribute); + MutablePropertyValues pvs = new MutablePropertyValues(propertyValues); + binder.bind(pvs); + try { + binder.close(); + model.putAll(binder.getBindingResult().getModel()); + } + catch (BindException ex) { + model.putAll(ex.getBindingResult().getModel()); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/AbstractWebRequestMatcherTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/AbstractWebRequestMatcherTests.java index 9d29b4767ad0..724dd0939bd0 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/AbstractWebRequestMatcherTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/AbstractWebRequestMatcherTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +19,7 @@ import java.net.MalformedURLException; import java.net.URL; -import com.gargoylesoftware.htmlunit.WebRequest; +import org.htmlunit.WebRequest; import static org.assertj.core.api.Assertions.assertThat; diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/DelegatingWebConnectionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/DelegatingWebConnectionTests.java index 25220ea31e2c..783a353b1557 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/DelegatingWebConnectionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/DelegatingWebConnectionTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,13 +19,13 @@ import java.net.URL; import java.util.Collections; -import com.gargoylesoftware.htmlunit.HttpWebConnection; -import com.gargoylesoftware.htmlunit.Page; -import com.gargoylesoftware.htmlunit.WebClient; -import com.gargoylesoftware.htmlunit.WebConnection; -import com.gargoylesoftware.htmlunit.WebRequest; -import com.gargoylesoftware.htmlunit.WebResponse; -import com.gargoylesoftware.htmlunit.WebResponseData; +import org.htmlunit.HttpWebConnection; +import org.htmlunit.Page; +import org.htmlunit.WebClient; +import org.htmlunit.WebConnection; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.htmlunit.WebResponseData; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/HtmlUnitRequestBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/HtmlUnitRequestBuilderTests.java index 31393ce73c90..96073fa71d33 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/HtmlUnitRequestBuilderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/HtmlUnitRequestBuilderTests.java @@ -24,15 +24,15 @@ import java.util.Locale; import java.util.Map; -import com.gargoylesoftware.htmlunit.FormEncodingType; -import com.gargoylesoftware.htmlunit.HttpMethod; -import com.gargoylesoftware.htmlunit.WebClient; -import com.gargoylesoftware.htmlunit.WebRequest; import jakarta.servlet.ServletContext; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpSession; import org.apache.commons.io.IOUtils; import org.apache.http.auth.UsernamePasswordCredentials; +import org.htmlunit.FormEncodingType; +import org.htmlunit.HttpMethod; +import org.htmlunit.WebClient; +import org.htmlunit.WebRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -340,11 +340,11 @@ void buildRequestLocalName() { } @Test - void buildRequestLocalPort() throws Exception { + void buildRequestLocalPortMatchingDefault() throws Exception { webRequest.setUrl(new URL("http://localhost:80/test/this/here")); MockHttpServletRequest actualRequest = requestBuilder.buildRequest(servletContext); - assertThat(actualRequest.getLocalPort()).isEqualTo(80); + assertThat(actualRequest.getLocalPort()).isEqualTo(-1); } @Test @@ -626,10 +626,18 @@ void buildRequestServerName() { @Test void buildRequestServerPort() throws Exception { - webRequest.setUrl(new URL("http://localhost:80/test/this/here")); + webRequest.setUrl(new URL("http://localhost:8080/test/this/here")); + MockHttpServletRequest actualRequest = requestBuilder.buildRequest(servletContext); + + assertThat(actualRequest.getServerPort()).isEqualTo(8080); + } + + @Test + void buildRequestServerPortMatchingDefault() throws Exception { + webRequest.setUrl(new URL("http://localhost/test/this/here")); MockHttpServletRequest actualRequest = requestBuilder.buildRequest(servletContext); - assertThat(actualRequest.getServerPort()).isEqualTo(80); + assertThat(actualRequest.getServerPort()).isEqualTo(-1); } @Test @@ -890,7 +898,7 @@ void mergeDoesNotCorruptPathInfoOnParent() throws Exception { private void assertSingleSessionCookie(String expected) { - com.gargoylesoftware.htmlunit.util.Cookie jsessionidCookie = webClient.getCookieManager().getCookie("JSESSIONID"); + org.htmlunit.util.Cookie jsessionidCookie = webClient.getCookieManager().getCookie("JSESSIONID"); if (expected == null || expected.contains("Expires=Thu, 01-Jan-1970 00:00:01 GMT")) { assertThat(jsessionidCookie).isNull(); return; diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockMvcConnectionBuilderSupportTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockMvcConnectionBuilderSupportTests.java index 666456651c95..d2574eb96c1f 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockMvcConnectionBuilderSupportTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockMvcConnectionBuilderSupportTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,11 +19,11 @@ import java.io.IOException; import java.net.URL; -import com.gargoylesoftware.htmlunit.WebClient; -import com.gargoylesoftware.htmlunit.WebConnection; -import com.gargoylesoftware.htmlunit.WebRequest; -import com.gargoylesoftware.htmlunit.WebResponse; import jakarta.servlet.http.HttpServletRequest; +import org.htmlunit.WebClient; +import org.htmlunit.WebConnection; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebClientBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebClientBuilderTests.java index fcf35bcba1dc..315baafb5393 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebClientBuilderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebClientBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,13 +19,13 @@ import java.io.IOException; import java.net.URL; -import com.gargoylesoftware.htmlunit.HttpMethod; -import com.gargoylesoftware.htmlunit.WebClient; -import com.gargoylesoftware.htmlunit.WebRequest; -import com.gargoylesoftware.htmlunit.WebResponse; -import com.gargoylesoftware.htmlunit.util.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.htmlunit.HttpMethod; +import org.htmlunit.WebClient; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.htmlunit.util.Cookie; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.Configuration; diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnectionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnectionTests.java index 9f2e3ca361b3..ab6359977125 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnectionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockMvcWebConnectionTests.java @@ -18,9 +18,9 @@ import java.io.IOException; -import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException; -import com.gargoylesoftware.htmlunit.Page; -import com.gargoylesoftware.htmlunit.WebClient; +import org.htmlunit.FailingHttpStatusCodeException; +import org.htmlunit.Page; +import org.htmlunit.WebClient; import org.junit.jupiter.api.Test; import org.springframework.test.web.servlet.MockMvc; diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockWebResponseBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockWebResponseBuilderTests.java index dd3473780b99..a38c09d52b96 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockWebResponseBuilderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/MockWebResponseBuilderTests.java @@ -20,10 +20,10 @@ import java.nio.charset.StandardCharsets; import java.util.List; -import com.gargoylesoftware.htmlunit.WebRequest; -import com.gargoylesoftware.htmlunit.WebResponse; -import com.gargoylesoftware.htmlunit.util.NameValuePair; import jakarta.servlet.http.Cookie; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.htmlunit.util.NameValuePair; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/webdriver/MockMvcHtmlUnitDriverBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/webdriver/MockMvcHtmlUnitDriverBuilderTests.java index 9555ab52af8d..1819b5298013 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/webdriver/MockMvcHtmlUnitDriverBuilderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/webdriver/MockMvcHtmlUnitDriverBuilderTests.java @@ -16,8 +16,8 @@ package org.springframework.test.web.servlet.htmlunit.webdriver; -import com.gargoylesoftware.htmlunit.util.Cookie; import jakarta.servlet.http.HttpServletRequest; +import org.htmlunit.util.Cookie; import org.junit.jupiter.api.Test; import org.openqa.selenium.htmlunit.HtmlUnitDriver; diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/webdriver/WebConnectionHtmlUnitDriverTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/webdriver/WebConnectionHtmlUnitDriverTests.java index 23869a8b3378..f9165fc5da3c 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/webdriver/WebConnectionHtmlUnitDriverTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/htmlunit/webdriver/WebConnectionHtmlUnitDriverTests.java @@ -18,8 +18,8 @@ import java.io.IOException; -import com.gargoylesoftware.htmlunit.WebConnection; -import com.gargoylesoftware.htmlunit.WebRequest; +import org.htmlunit.WebConnection; +import org.htmlunit.WebRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilderTests.java new file mode 100644 index 000000000000..ef7c577f6fe5 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilderTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.request; + +import java.net.URI; + +import jakarta.servlet.ServletContext; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockServletContext; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AbstractMockHttpServletRequestBuilder} + * + * @author Stephane Nicoll + */ +class AbstractMockHttpServletRequestBuilderTests { + + private final ServletContext servletContext = new MockServletContext(); + + @Test + void uriTemplateSetsRequestsUrlAndTemplateConsistently() { + MockHttpServletRequest request = buildRequest(new TestRequestBuilder(HttpMethod.GET).uri("/hotels/{id}", 42)); + assertThat(request.getRequestURL().toString()).isEqualTo("http://localhost/hotels/42"); + assertThat(request.getUriTemplate()).isEqualTo("/hotels/{id}"); + } + + @Test + void uriSetsRequestsUrlAndTemplateConsistently() { + MockHttpServletRequest request = buildRequest(new TestRequestBuilder(HttpMethod.GET).uri("/hotels/{id}", 42) + .uri(URI.create("/hotels/25"))); + assertThat(request.getRequestURL().toString()).isEqualTo("http://localhost/hotels/25"); + assertThat(request.getUriTemplate()).isNull(); + } + + @Test + void mergeUriTemplateWhenUriTemplateIsNotSet() { + TestRequestBuilder parentBuilder = new TestRequestBuilder(HttpMethod.GET).uri("/hotels/{id}", 42); + TestRequestBuilder builder = new TestRequestBuilder(HttpMethod.POST); + builder.merge(parentBuilder); + + MockHttpServletRequest request = buildRequest(builder); + assertThat(request.getUriTemplate()).isEqualTo("/hotels/{id}"); + assertThat(request.getRequestURL().toString()).isEqualTo("http://localhost/hotels/42"); + } + + + @Test + void mergeUriTemplateWhenUriIsSetDoesNotMergeUriTemplate() { + TestRequestBuilder parentBuilder = new TestRequestBuilder(HttpMethod.GET).uri("/hotels/{id}", 42); + TestRequestBuilder builder = new TestRequestBuilder(HttpMethod.POST).uri(URI.create("/hotels/35")); + builder.merge(parentBuilder); + + MockHttpServletRequest request = buildRequest(builder); + assertThat(request.getUriTemplate()).isNull(); + assertThat(request.getRequestURL().toString()).isEqualTo("http://localhost/hotels/35"); + } + + @Test + void mergeUriTemplateWhenUriTemplateIsSetDoesNotMergeUriTemplate() { + TestRequestBuilder parentBuilder = new TestRequestBuilder(HttpMethod.GET).uri("/hotels/{id}", 42); + TestRequestBuilder builder = new TestRequestBuilder(HttpMethod.POST).uri("/users/{id}", 25); + builder.merge(parentBuilder); + + MockHttpServletRequest request = buildRequest(builder); + assertThat(request.getUriTemplate()).isEqualTo("/users/{id}"); + assertThat(request.getRequestURL().toString()).isEqualTo("http://localhost/users/25"); + } + + @Test + void mergeUriWhenUriIsSetDoesNotOverride() { + TestRequestBuilder parentBuilder = new TestRequestBuilder(HttpMethod.GET).uri("/test"); + TestRequestBuilder builder = new TestRequestBuilder(HttpMethod.POST).uri("/another"); + builder.merge(parentBuilder); + + MockHttpServletRequest request = buildRequest(builder); + assertThat(request.getRequestURI()).isEqualTo("/another"); + assertThat(request.getMethod()).isEqualTo(HttpMethod.POST.name()); + } + + + private MockHttpServletRequest buildRequest(AbstractMockHttpServletRequestBuilder builder) { + return builder.buildRequest(this.servletContext); + } + + + private static class TestRequestBuilder extends AbstractMockHttpServletRequestBuilder { + + TestRequestBuilder(HttpMethod httpMethod) { + super(httpMethod); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/request/AbstractMockMultipartHttpServletRequestBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/request/AbstractMockMultipartHttpServletRequestBuilderTests.java new file mode 100644 index 000000000000..0ae56091fbd0 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/request/AbstractMockMultipartHttpServletRequestBuilderTests.java @@ -0,0 +1,109 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.request; + +import jakarta.servlet.ServletContext; +import jakarta.servlet.http.Part; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.mock.web.MockMultipartHttpServletRequest; +import org.springframework.mock.web.MockPart; +import org.springframework.mock.web.MockServletContext; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AbstractMockMultipartHttpServletRequestBuilder}. + * + * @author Stephane Nicoll + */ +public class AbstractMockMultipartHttpServletRequestBuilderTests { + + private final ServletContext servletContext = new MockServletContext(); + + @Test // gh-26166 + void addFileAndParts() throws Exception { + MockMultipartHttpServletRequest mockRequest = + (MockMultipartHttpServletRequest) createBuilder("/upload") + .file(new MockMultipartFile("file", "test.txt", "text/plain", "Test".getBytes(UTF_8))) + .part(new MockPart("name", "value".getBytes(UTF_8))) + .buildRequest(new MockServletContext()); + + assertThat(mockRequest.getFileMap()).containsOnlyKeys("file"); + assertThat(mockRequest.getParameterMap()).containsOnlyKeys("name"); + assertThat(mockRequest.getParts()).extracting(Part::getName).containsExactly("name"); + } + + @Test // gh-26261, gh-26400 + void addFileWithoutFilename() throws Exception { + MockPart jsonPart = new MockPart("data", "{\"node\":\"node\"}".getBytes(UTF_8)); + jsonPart.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + MockMultipartHttpServletRequest mockRequest = + (MockMultipartHttpServletRequest) createBuilder("/upload") + .file(new MockMultipartFile("file", "Test".getBytes(UTF_8))) + .part(jsonPart) + .buildRequest(new MockServletContext()); + + assertThat(mockRequest.getFileMap()).containsOnlyKeys("file"); + assertThat(mockRequest.getParameterMap()).hasSize(1); + assertThat(mockRequest.getParameter("data")).isEqualTo("{\"node\":\"node\"}"); + assertThat(mockRequest.getParts()).extracting(Part::getName).containsExactly("data"); + } + + @Test + void mergeAndBuild() { + MockHttpServletRequestBuilder parent = new MockHttpServletRequestBuilder(HttpMethod.GET).uri("/"); + parent.characterEncoding("UTF-8"); + Object result = createBuilder("/fileUpload").merge(parent); + + assertThat(result).isNotNull(); + assertThat(result.getClass()).isEqualTo(TestRequestBuilder.class); + + TestRequestBuilder builder = (TestRequestBuilder) result; + MockHttpServletRequest request = builder.buildRequest(new MockServletContext()); + assertThat(request.getCharacterEncoding()).isEqualTo("UTF-8"); + } + + + @Test + void builderSetsRequestContentType() { + MockHttpServletRequest request = buildRequest(createBuilder("/upload")); + assertThat(request.getContentType()).isEqualTo(MediaType.MULTIPART_FORM_DATA_VALUE); + } + + private TestRequestBuilder createBuilder(String uri) { + return new TestRequestBuilder(HttpMethod.POST).uri(uri); + } + + private MockHttpServletRequest buildRequest(AbstractMockHttpServletRequestBuilder builder) { + return builder.buildRequest(this.servletContext); + } + + + private static class TestRequestBuilder extends AbstractMockMultipartHttpServletRequestBuilder { + + TestRequestBuilder(HttpMethod httpMethod) { + super(httpMethod); + } + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java index 35409a266422..6b0dd46fcdb5 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java @@ -33,6 +33,7 @@ import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpSession; @@ -64,7 +65,7 @@ class MockHttpServletRequestBuilderTests { private final ServletContext servletContext = new MockServletContext(); - private MockHttpServletRequestBuilder builder = new MockHttpServletRequestBuilder(GET, "/foo/bar"); + private MockHttpServletRequestBuilder builder = new MockHttpServletRequestBuilder(GET).uri("/foo/bar"); @Test @@ -77,7 +78,7 @@ void method() { @Test void uri() { String uri = "https://java.sun.com:8080/javase/6/docs/api/java/util/BitSet.html?foo=bar#and(java.util.BitSet)"; - this.builder = new MockHttpServletRequestBuilder(GET, uri); + this.builder = new MockHttpServletRequestBuilder(GET).uri(uri); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); assertThat(request.getScheme()).isEqualTo("https"); @@ -91,7 +92,7 @@ void uri() { @Test void requestUriWithEncoding() { - this.builder = new MockHttpServletRequestBuilder(GET, "/foo bar"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/foo bar"); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); assertThat(request.getRequestURI()).isEqualTo("/foo%20bar"); @@ -99,7 +100,7 @@ void requestUriWithEncoding() { @Test // SPR-13435 void requestUriWithDoubleSlashes() { - this.builder = new MockHttpServletRequestBuilder(GET, URI.create("/test//currentlyValid/0")); + this.builder = new MockHttpServletRequestBuilder(GET).uri(URI.create("/test//currentlyValid/0")); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); assertThat(request.getRequestURI()).isEqualTo("/test//currentlyValid/0"); @@ -108,12 +109,12 @@ void requestUriWithDoubleSlashes() { @Test // gh-24556 void requestUriWithoutScheme() { assertThatIllegalArgumentException().isThrownBy(() -> MockMvcRequestBuilders.get("localhost:8080/path")) - .withMessage("'url' should start with a path or be a complete HTTP URL: localhost:8080/path"); + .withMessage("'uri' should start with a path or be a complete HTTP URI: localhost:8080/path"); } @Test void contextPathEmpty() { - this.builder = new MockHttpServletRequestBuilder(GET, "/foo"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/foo"); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); assertThat(request.getContextPath()).isEqualTo(""); @@ -123,7 +124,7 @@ void contextPathEmpty() { @Test void contextPathServletPathEmpty() { - this.builder = new MockHttpServletRequestBuilder(GET, "/travel/hotels/42"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/travel/hotels/42"); this.builder.contextPath("/travel"); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); @@ -134,7 +135,7 @@ void contextPathServletPathEmpty() { @Test void contextPathServletPath() { - this.builder = new MockHttpServletRequestBuilder(GET, "/travel/main/hotels/42"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/travel/main/hotels/42"); this.builder.contextPath("/travel"); this.builder.servletPath("/main"); @@ -147,7 +148,7 @@ void contextPathServletPath() { @Test void contextPathServletPathInfoEmpty() { - this.builder = new MockHttpServletRequestBuilder(GET, "/travel/hotels/42"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/travel/hotels/42"); this.builder.contextPath("/travel"); this.builder.servletPath("/hotels/42"); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); @@ -159,7 +160,7 @@ void contextPathServletPathInfoEmpty() { @Test void contextPathServletPathInfo() { - this.builder = new MockHttpServletRequestBuilder(GET, "/"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/"); this.builder.servletPath("/index.html"); this.builder.pathInfo(null); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); @@ -171,7 +172,7 @@ void contextPathServletPathInfo() { @Test // gh-28823, gh-29933 void emptyPath() { - this.builder = new MockHttpServletRequestBuilder(GET, ""); + this.builder = new MockHttpServletRequestBuilder(GET).uri(""); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); assertThat(request.getRequestURI()).isEqualTo("/"); @@ -182,7 +183,7 @@ void emptyPath() { @Test // SPR-16453 void pathInfoIsDecoded() { - this.builder = new MockHttpServletRequestBuilder(GET, "/travel/hotels 42"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/travel/hotels 42"); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); assertThat(request.getPathInfo()).isEqualTo("/travel/hotels 42"); @@ -212,7 +213,7 @@ private void testContextPathServletPathInvalid(String contextPath, String servle @Test void requestUriAndFragment() { - this.builder = new MockHttpServletRequestBuilder(GET, "/foo#bar"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/foo#bar"); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); assertThat(request.getRequestURI()).isEqualTo("/foo"); @@ -230,7 +231,7 @@ void requestParameter() { @Test void requestParameterFromQuery() { - this.builder = new MockHttpServletRequestBuilder(GET, "/?foo=bar&foo=baz"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/?foo=bar&foo=baz"); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); Map parameterMap = request.getParameterMap(); @@ -241,7 +242,7 @@ void requestParameterFromQuery() { @Test void requestParameterFromQueryList() { - this.builder = new MockHttpServletRequestBuilder(GET, "/?foo[0]=bar&foo[1]=baz"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/?foo[0]=bar&foo[1]=baz"); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); @@ -252,7 +253,7 @@ void requestParameterFromQueryList() { @Test void queryParameter() { - this.builder = new MockHttpServletRequestBuilder(GET, "/"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/"); this.builder.queryParam("foo", "bar"); this.builder.queryParam("foo", "baz"); @@ -264,7 +265,7 @@ void queryParameter() { @Test void queryParameterMap() { - this.builder = new MockHttpServletRequestBuilder(GET, "/"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/"); MultiValueMap queryParams = new LinkedMultiValueMap<>(); List values = new ArrayList<>(); values.add("bar"); @@ -280,7 +281,7 @@ void queryParameterMap() { @Test void queryParameterList() { - this.builder = new MockHttpServletRequestBuilder(GET, "/"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/"); this.builder.queryParam("foo[0]", "bar"); this.builder.queryParam("foo[1]", "baz"); @@ -293,7 +294,7 @@ void queryParameterList() { @Test void formField() { - this.builder = new MockHttpServletRequestBuilder(POST, "/"); + this.builder = new MockHttpServletRequestBuilder(POST).uri("/"); this.builder.formField("foo", "bar"); this.builder.formField("foo", "baz"); @@ -305,7 +306,7 @@ void formField() { @Test void formFieldMap() { - this.builder = new MockHttpServletRequestBuilder(POST, "/"); + this.builder = new MockHttpServletRequestBuilder(POST).uri("/"); MultiValueMap formFields = new LinkedMultiValueMap<>(); List values = new ArrayList<>(); values.add("bar"); @@ -321,7 +322,7 @@ void formFieldMap() { @Test void formFieldsAreEncoded() { - MockHttpServletRequest request = new MockHttpServletRequestBuilder(POST, "/") + MockHttpServletRequest request = new MockHttpServletRequestBuilder(POST).uri("/") .formField("name 1", "value 1").formField("name 2", "value A", "value B") .buildRequest(new MockServletContext()); assertThat(request.getParameterMap()).containsOnly( @@ -332,7 +333,7 @@ void formFieldsAreEncoded() { @Test void formFieldWithContent() { - this.builder = new MockHttpServletRequestBuilder(POST, "/"); + this.builder = new MockHttpServletRequestBuilder(POST).uri("/"); this.builder.content("Should not have content"); this.builder.formField("foo", "bar"); assertThatIllegalStateException().isThrownBy(() -> this.builder.buildRequest(this.servletContext)) @@ -341,7 +342,7 @@ void formFieldWithContent() { @Test void formFieldWithIncompatibleMediaType() { - this.builder = new MockHttpServletRequestBuilder(POST, "/"); + this.builder = new MockHttpServletRequestBuilder(POST).uri("/"); this.builder.contentType(MediaType.TEXT_PLAIN); this.builder.formField("foo", "bar"); assertThatIllegalStateException().isThrownBy(() -> this.builder.buildRequest(this.servletContext)) @@ -357,7 +358,7 @@ private ThrowingConsumer hasFormData(String body) { @Test void requestParameterFromQueryWithEncoding() { - this.builder = new MockHttpServletRequestBuilder(GET, "/?foo={value}", "bar=baz"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/?foo={value}", "bar=baz"); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); @@ -367,7 +368,7 @@ void requestParameterFromQueryWithEncoding() { @Test // SPR-11043 void requestParameterFromQueryNull() { - this.builder = new MockHttpServletRequestBuilder(GET, "/?foo"); + this.builder = new MockHttpServletRequestBuilder(GET).uri("/?foo"); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); Map parameterMap = request.getParameterMap(); @@ -381,7 +382,7 @@ void requestParameterFromMultiValueMap() { MultiValueMap params = new LinkedMultiValueMap<>(); params.add("foo", "bar"); params.add("foo", "baz"); - this.builder = new MockHttpServletRequestBuilder(POST, "/foo"); + this.builder = new MockHttpServletRequestBuilder(POST).uri("/foo"); this.builder.params(params); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); @@ -394,7 +395,7 @@ void requestParameterFromRequestBodyFormData() { String contentType = "application/x-www-form-urlencoded;charset=UTF-8"; String body = "name+1=value+1&name+2=value+A&name+2=value+B&name+3"; - MockHttpServletRequest request = new MockHttpServletRequestBuilder(POST, "/foo") + MockHttpServletRequest request = new MockHttpServletRequestBuilder(POST).uri("/foo") .contentType(contentType).content(body.getBytes(UTF_8)) .buildRequest(this.servletContext); @@ -634,7 +635,7 @@ void mergeInvokesDefaultRequestPostProcessorFirst() { final String EXPECTED = "override"; MockHttpServletRequestBuilder defaultBuilder = - new MockHttpServletRequestBuilder(GET, "/foo/bar") + new MockHttpServletRequestBuilder(GET).uri("/foo/bar") .with(requestAttr(ATTR).value("default")) .with(requestAttr(ATTR).value(EXPECTED)); @@ -650,7 +651,7 @@ void mergeInvokesDefaultRequestPostProcessorFirst() { void arbitraryMethod() { String httpMethod = "REPort"; URI url = UriComponentsBuilder.fromPath("/foo/{bar}").buildAndExpand(42).toUri(); - this.builder = new MockHttpServletRequestBuilder(httpMethod, url); + this.builder = new MockHttpServletRequestBuilder(HttpMethod.valueOf(httpMethod)).uri(url); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); assertThat(request.getMethod()).isEqualTo(httpMethod); diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockMultipartHttpServletRequestBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockMultipartHttpServletRequestBuilderTests.java index 897a882a7de2..e8c3cfdaddd6 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockMultipartHttpServletRequestBuilderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockMultipartHttpServletRequestBuilderTests.java @@ -39,7 +39,7 @@ public class MockMultipartHttpServletRequestBuilderTests { @Test // gh-26166 void addFileAndParts() throws Exception { MockMultipartHttpServletRequest mockRequest = - (MockMultipartHttpServletRequest) new MockMultipartHttpServletRequestBuilder("/upload") + (MockMultipartHttpServletRequest) createBuilder("/upload") .file(new MockMultipartFile("file", "test.txt", "text/plain", "Test".getBytes(UTF_8))) .part(new MockPart("name", "value".getBytes(UTF_8))) .buildRequest(new MockServletContext()); @@ -55,7 +55,7 @@ void addFileWithoutFilename() throws Exception { jsonPart.getHeaders().setContentType(MediaType.APPLICATION_JSON); MockMultipartHttpServletRequest mockRequest = - (MockMultipartHttpServletRequest) new MockMultipartHttpServletRequestBuilder("/upload") + (MockMultipartHttpServletRequest) createBuilder("/upload") .file(new MockMultipartFile("file", "Test".getBytes(UTF_8))) .part(jsonPart) .buildRequest(new MockServletContext()); @@ -68,9 +68,9 @@ void addFileWithoutFilename() throws Exception { @Test void mergeAndBuild() { - MockHttpServletRequestBuilder parent = new MockHttpServletRequestBuilder(HttpMethod.GET, "/"); + MockHttpServletRequestBuilder parent = new MockHttpServletRequestBuilder(HttpMethod.GET).uri("/"); parent.characterEncoding("UTF-8"); - Object result = new MockMultipartHttpServletRequestBuilder("/fileUpload").merge(parent); + Object result = createBuilder("/fileUpload").merge(parent); assertThat(result).isNotNull(); assertThat(result.getClass()).isEqualTo(MockMultipartHttpServletRequestBuilder.class); @@ -80,4 +80,10 @@ void mergeAndBuild() { assertThat(request.getCharacterEncoding()).isEqualTo("UTF-8"); } + private MockMultipartHttpServletRequestBuilder createBuilder(String uri) { + MockMultipartHttpServletRequestBuilder builder = new MockMultipartHttpServletRequestBuilder(); + builder.uri(uri); + return builder; + } + } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/result/ContentResultMatchersTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/result/ContentResultMatchersTests.java index fa98d3d1ea2d..4c28ae7fb938 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/result/ContentResultMatchersTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/result/ContentResultMatchersTests.java @@ -20,6 +20,7 @@ import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.json.JsonCompareMode; import org.springframework.test.web.servlet.StubMvcResult; import static java.nio.charset.StandardCharsets.UTF_8; @@ -80,13 +81,31 @@ void bytesNoMatch() { @Test void jsonLenientMatch() throws Exception { new ContentResultMatchers().json("{\n \"foo\" : \"bar\" \n}").match(getStubMvcResult(CONTENT)); + new ContentResultMatchers().json("{\n \"foo\" : \"bar\" \n}", + JsonCompareMode.LENIENT).match(getStubMvcResult(CONTENT)); + } + + @Test + @Deprecated + void jsonLenientMatchWithDeprecatedBooleanFlag() throws Exception { new ContentResultMatchers().json("{\n \"foo\" : \"bar\" \n}", false).match(getStubMvcResult(CONTENT)); } @Test void jsonStrictMatch() throws Exception { - new ContentResultMatchers().json("{\n \"foo\":\"bar\", \"foo array\":[\"foo\",\"bar\"] \n}", true).match(getStubMvcResult(CONTENT)); - new ContentResultMatchers().json("{\n \"foo array\":[\"foo\",\"bar\"], \"foo\":\"bar\" \n}", true).match(getStubMvcResult(CONTENT)); + new ContentResultMatchers().json("{\n \"foo\":\"bar\", \"foo array\":[\"foo\",\"bar\"] \n}", + JsonCompareMode.STRICT).match(getStubMvcResult(CONTENT)); + new ContentResultMatchers().json("{\n \"foo array\":[\"foo\",\"bar\"], \"foo\":\"bar\" \n}", + JsonCompareMode.STRICT).match(getStubMvcResult(CONTENT)); + } + + @Test + @Deprecated + void jsonStrictMatchWithDeprecatedBooleanFlag() throws Exception { + new ContentResultMatchers().json("{\n \"foo\":\"bar\", \"foo array\":[\"foo\",\"bar\"] \n}", true) + .match(getStubMvcResult(CONTENT)); + new ContentResultMatchers().json("{\n \"foo array\":[\"foo\",\"bar\"], \"foo\":\"bar\" \n}", true) + .match(getStubMvcResult(CONTENT)); } @Test @@ -98,7 +117,16 @@ void jsonLenientNoMatch() { @Test void jsonStrictNoMatch() { assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - new ContentResultMatchers().json("{\"foo\":\"bar\", \"foo array\":[\"bar\",\"foo\"]}", true).match(getStubMvcResult(CONTENT))); + new ContentResultMatchers().json("{\"foo\":\"bar\", \"foo array\":[\"bar\",\"foo\"]}", + JsonCompareMode.STRICT).match(getStubMvcResult(CONTENT))); + } + + @Test + @Deprecated + void jsonStrictNoMatchWithDeprecatedBooleanFlag() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + new ContentResultMatchers().json("{\"foo\":\"bar\", \"foo array\":[\"bar\",\"foo\"]}", true) + .match(getStubMvcResult(CONTENT))); } @Test // gh-23622 diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/AsyncControllerJavaConfigTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/AsyncControllerJavaConfigTests.java index 53dc815f1a13..40b74b786118 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/AsyncControllerJavaConfigTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/AsyncControllerJavaConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ @ExtendWith(SpringExtension.class) @WebAppConfiguration @ContextHierarchy(@ContextConfiguration(classes = AsyncControllerJavaConfigTests.WebConfig.class)) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") public class AsyncControllerJavaConfigTests { @Autowired diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/JavaConfigTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/JavaConfigTests.java index 4e0a436987ac..2d24a331b947 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/JavaConfigTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/JavaConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ @ContextConfiguration(classes = JavaConfigTests.RootConfig.class), @ContextConfiguration(classes = JavaConfigTests.WebConfig.class) }) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") class JavaConfigTests { @Autowired diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/WebAppResourceTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/WebAppResourceTests.java index f40370290eaf..fb748a0d62be 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/WebAppResourceTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/WebAppResourceTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +48,7 @@ @ContextConfiguration("../../context/root-context.xml"), @ContextConfiguration("../../context/servlet-context.xml") }) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") public class WebAppResourceTests { @Autowired @@ -69,7 +69,7 @@ public void resourceRequest() { testClient.get().uri("/resources/Spring.js") .exchange() .expectStatus().isOk() - .expectHeader().contentType("application/javascript") + .expectHeader().contentType("text/javascript") .expectBody(String.class).value(containsString("Spring={};")); } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/XmlConfigTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/XmlConfigTests.java index f2630c427adf..7e8a4c47ebac 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/XmlConfigTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/XmlConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -47,7 +47,7 @@ @ContextConfiguration("../../context/root-context.xml"), @ContextConfiguration("../../context/servlet-context.xml") }) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") public class XmlConfigTests { @Autowired 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 2de69a305aa2..29c8367c9452 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 @@ -194,7 +194,7 @@ DeferredResult getDeferredResultWithDelayedError() { } @GetMapping(params = "listenableFuture") - @SuppressWarnings("deprecation") + @SuppressWarnings({ "deprecation", "removal" }) org.springframework.util.concurrent.ListenableFuture getListenableFuture() { org.springframework.util.concurrent.ListenableFutureTask futureTask = new org.springframework.util.concurrent.ListenableFutureTask<>(() -> new Person("Joe")); diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/MultipartControllerTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/MultipartControllerTests.java index 78b9cb2748ca..f65342982090 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/MultipartControllerTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/MultipartControllerTests.java @@ -36,6 +36,7 @@ import org.springframework.stereotype.Controller; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.test.web.servlet.client.MockMvcWebTestClient; +import org.springframework.util.CollectionUtils; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -271,7 +272,7 @@ public String processMultipartFileArray(@RequestParam(required = false) Multipar public String processMultipartFileList(@RequestParam(required = false) List file, @RequestPart(required = false) Map json) throws IOException { - if (file != null && !file.isEmpty()) { + if (!CollectionUtils.isEmpty(file)) { byte[] content = file.get(0).getBytes(); assertThat(file.get(1).getBytes()).isEqualTo(content); } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ResponseBodyTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ResponseBodyTests.java index 691a60e8040e..2b01b907fd3d 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ResponseBodyTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ResponseBodyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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,15 +16,21 @@ package org.springframework.test.web.servlet.samples.client.standalone; +import java.util.List; +import java.util.function.Consumer; + import jakarta.validation.constraints.NotNull; import org.junit.jupiter.api.Test; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient.BodyContentSpec; import org.springframework.test.web.servlet.client.MockMvcWebTestClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.equalTo; /** @@ -32,60 +38,64 @@ * {@link org.springframework.test.web.servlet.samples.standalone.ResponseBodyTests}. * * @author Rossen Stoyanchev + * @author Stephane Nicoll */ class ResponseBodyTests { @Test void json() { - MockMvcWebTestClient.bindToController(new PersonController()).build() - .get() - .uri("/person/Lee") - .accept(MediaType.APPLICATION_JSON) - .exchange() - .expectStatus().isOk() - .expectHeader().contentType(MediaType.APPLICATION_JSON) - .expectBody() - .jsonPath("$.name").isEqualTo("Lee") + execute("/persons/Lee", body -> body.jsonPath("$.name").isEqualTo("Lee") .jsonPath("$.age").isEqualTo(42) .jsonPath("$.age").value(equalTo(42)) - .jsonPath("$.age").value(equalTo(42.0f), Float.class); + .jsonPath("$.age").value(Float.class, equalTo(42.0f))); } - - @RestController - private static class PersonController { - - @GetMapping("/person/{name}") - Person get(@PathVariable String name) { - Person person = new Person(name); - person.setAge(42); - return person; - } + @Test + void jsonPathWithCustomType() { + execute("/persons/Lee", body -> body.jsonPath("$").isEqualTo(new Person("Lee", 42))); } - @SuppressWarnings("unused") - private static class Person { + @Test + void jsonPathWithResolvedValue() { + execute("/persons/Lee", body -> body.jsonPath("$").value(Person.class, + candidate -> assertThat(candidate).isEqualTo(new Person("Lee", 42)))); + } - @NotNull - private final String name; + @Test + void jsonPathWithResolvedGenericValue() { + execute("/persons", body -> body.jsonPath("$").value(new ParameterizedTypeReference>() {}, + candidate -> assertThat(candidate).hasSize(3).extracting(Person::name) + .containsExactly("Rossen", "Juergen", "Arjen"))); + } - private int age; + private void execute(String uri, Consumer assertions) { + assertions.accept(MockMvcWebTestClient.bindToController(new PersonController()).build() + .get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody()); + } - public Person(String name) { - this.name = name; - } - public String getName() { - return this.name; - } + @RestController + @SuppressWarnings("unused") + private static class PersonController { - public int getAge() { - return this.age; + @GetMapping("/persons") + List getAll() { + return List.of(new Person("Rossen", 42), new Person("Juergen", 42), + new Person("Arjen", 42)); } - public void setAge(int age) { - this.age = age; + @GetMapping("/persons/{name}") + Person get(@PathVariable String name) { + return new Person(name, 42); } } + private record Person(@NotNull String name, int age) {} + } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/RouterFunctionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/RouterFunctionTests.java new file mode 100644 index 000000000000..ad0c9ff48798 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/RouterFunctionTests.java @@ -0,0 +1,137 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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 java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.servlet.client.MockMvcWebTestClient; +import org.springframework.web.servlet.function.RequestPredicates; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.hamcrest.Matchers.equalTo; +import static org.springframework.web.servlet.function.RouterFunctions.route; +import static org.springframework.web.servlet.function.ServerResponse.ok; + +/** + * MockMvcTestClient equivalent of the MockMvc + * {@link org.springframework.test.web.servlet.samples.standalone.RouterFunctionTests}. + * + * @author Arjen Poutsma + */ +public class RouterFunctionTests { + + @Test + void json() { + execute("/person/Lee", body -> body.jsonPath("$.name").isEqualTo("Lee") + .jsonPath("$.age").isEqualTo(42) + .jsonPath("$.age").value(equalTo(42)) + .jsonPath("$.age").value(Float.class, equalTo(42.0f))); + } + + @Test + public void queryParameter() { + execute("/search?name=George", body -> body.jsonPath("$.name").isEqualTo("George")); + } + + + @Nested + class AsyncTests { + + @Test + void completableFuture() { + execute("/async/completableFuture", body -> body.json("{\"name\":\"Joe\",\"age\":0}")); + } + + @Test + void publisher() { + execute("/async/publisher", body -> body.json("{\"name\":\"Joe\",\"age\":0}")); + } + + } + + + private void execute(String uri, Consumer assertions) { + RouterFunction testRoute = testRoute(); + assertions.accept(MockMvcWebTestClient.bindToRouterFunction(testRoute).build() + .get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody()); + } + + private static RouterFunction testRoute() { + return route() + .GET("/person/{name}", request -> { + Person person = new Person(request.pathVariable("name")); + person.setAge(42); + return ok().body(person); + }) + .GET("/search", request -> { + String name = request.param("name").orElseThrow(NullPointerException::new); + Person person = new Person(name); + return ok().body(person); + }) + .path("/async", b -> b + .GET("/completableFuture", request -> { + CompletableFuture future = new CompletableFuture<>(); + future.complete(new Person("Joe")); + return ok().body(future); + }) + .GET("/publisher", request -> { + Mono mono = Mono.just(new Person("Joe")); + return ok().body(mono); + }) + ) + .route(RequestPredicates.all(), request -> ServerResponse.notFound().build()) + .build(); + } + + @SuppressWarnings("unused") + private static class Person { + + private final String name; + + private int age; + + public Person(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + public int getAge() { + return this.age; + } + + public void setAge(int age) { + this.age = age; + } + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/HeaderAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/HeaderAssertionTests.java index b623995b0434..9c1cbccfcb00 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/HeaderAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/HeaderAssertionTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +35,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.context.request.WebRequest; -import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.fail; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItems; @@ -53,10 +51,7 @@ * * @author Rossen Stoyanchev */ -public class HeaderAssertionTests { - - private static final String ERROR_MESSAGE = "Should have thrown an AssertionError"; - +class HeaderAssertionTests { private String now; @@ -70,7 +65,7 @@ public class HeaderAssertionTests { @BeforeEach - public void setup() { + void setup() { this.dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US); this.dateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); this.now = dateFormat.format(new Date(this.currentTime)); @@ -83,7 +78,7 @@ public void setup() { @Test - public void stringWithCorrectResponseHeaderValue() { + void stringWithCorrectResponseHeaderValue() { testClient.get().uri("/persons/1").header(IF_MODIFIED_SINCE, minuteAgo) .exchange() .expectStatus().isOk() @@ -91,7 +86,7 @@ public void stringWithCorrectResponseHeaderValue() { } @Test - public void stringWithMatcherAndCorrectResponseHeaderValue() { + void stringWithMatcherAndCorrectResponseHeaderValue() { testClient.get().uri("/persons/1").header(IF_MODIFIED_SINCE, minuteAgo) .exchange() .expectStatus().isOk() @@ -99,7 +94,7 @@ public void stringWithMatcherAndCorrectResponseHeaderValue() { } @Test - public void multiStringHeaderValue() { + void multiStringHeaderValue() { testClient.get().uri("/persons/1") .exchange() .expectStatus().isOk() @@ -107,7 +102,7 @@ public void multiStringHeaderValue() { } @Test - public void multiStringHeaderValueWithMatchers() { + void multiStringHeaderValueWithMatchers() { testClient.get().uri("/persons/1") .exchange() .expectStatus().isOk() @@ -115,7 +110,7 @@ public void multiStringHeaderValueWithMatchers() { } @Test - public void dateValueWithCorrectResponseHeaderValue() { + void dateValueWithCorrectResponseHeaderValue() { testClient.get().uri("/persons/1") .header(IF_MODIFIED_SINCE, minuteAgo) .exchange() @@ -124,7 +119,7 @@ public void dateValueWithCorrectResponseHeaderValue() { } @Test - public void longValueWithCorrectResponseHeaderValue() { + void longValueWithCorrectResponseHeaderValue() { testClient.get().uri("/persons/1") .exchange() .expectStatus().isOk() @@ -132,7 +127,7 @@ public void longValueWithCorrectResponseHeaderValue() { } @Test - public void stringWithMissingResponseHeader() { + void stringWithMissingResponseHeader() { testClient.get().uri("/persons/1") .header(IF_MODIFIED_SINCE, now) .exchange() @@ -141,7 +136,7 @@ public void stringWithMissingResponseHeader() { } @Test - public void stringWithMatcherAndMissingResponseHeader() { + void stringWithMatcherAndMissingResponseHeader() { testClient.get().uri("/persons/1").header(IF_MODIFIED_SINCE, now) .exchange() .expectStatus().isNotModified() @@ -149,25 +144,18 @@ public void stringWithMatcherAndMissingResponseHeader() { } @Test - public void longValueWithMissingResponseHeader() { - try { - testClient.get().uri("/persons/1").header(IF_MODIFIED_SINCE, now) - .exchange() - .expectStatus().isNotModified() - .expectHeader().valueEquals("X-Custom-Header", 99L); - - fail(ERROR_MESSAGE); - } - catch (AssertionError err) { - if (ERROR_MESSAGE.equals(err.getMessage())) { - throw err; - } - assertThat(err.getMessage()).startsWith("Response does not contain header 'X-Custom-Header'"); - } + void longValueWithMissingResponseHeader() { + String headerName = "X-Custom-Header"; + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + testClient.get().uri("/persons/1").header(IF_MODIFIED_SINCE, now) + .exchange() + .expectStatus().isNotModified() + .expectHeader().valueEquals(headerName, 99L)) + .withMessage("Response does not contain header '%s'", headerName); } @Test - public void exists() { + void exists() { testClient.get().uri("/persons/1") .exchange() .expectStatus().isOk() @@ -175,7 +163,7 @@ public void exists() { } @Test - public void existsFail() { + void existsFail() { assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> testClient.get().uri("/persons/1") .exchange() @@ -184,7 +172,7 @@ public void existsFail() { } @Test - public void doesNotExist() { + void doesNotExist() { testClient.get().uri("/persons/1") .exchange() .expectStatus().isOk() @@ -192,7 +180,7 @@ public void doesNotExist() { } @Test - public void doesNotExistFail() { + void doesNotExistFail() { assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> testClient.get().uri("/persons/1") .exchange() @@ -201,7 +189,7 @@ public void doesNotExistFail() { } @Test - public void longValueWithIncorrectResponseHeaderValue() { + void longValueWithIncorrectResponseHeaderValue() { assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> testClient.get().uri("/persons/1") .exchange() @@ -210,7 +198,7 @@ public void longValueWithIncorrectResponseHeaderValue() { } @Test - public void stringWithMatcherAndIncorrectResponseHeaderValue() { + void stringWithMatcherAndIncorrectResponseHeaderValue() { long secondLater = this.currentTime + 1000; String expected = this.dateFormat.format(new Date(secondLater)); assertIncorrectResponseHeader(spec -> spec.expectHeader().valueEquals(LAST_MODIFIED, expected), expected); @@ -222,30 +210,13 @@ public void stringWithMatcherAndIncorrectResponseHeaderValue() { } private void assertIncorrectResponseHeader(Consumer assertions, String expected) { - try { - WebTestClient.ResponseSpec spec = testClient.get().uri("/persons/1") - .header(IF_MODIFIED_SINCE, minuteAgo) - .exchange() - .expectStatus().isOk(); - - assertions.accept(spec); - - fail(ERROR_MESSAGE); - } - catch (AssertionError err) { - if (ERROR_MESSAGE.equals(err.getMessage())) { - throw err; - } - assertMessageContains(err, "Response header '" + LAST_MODIFIED + "'"); - assertMessageContains(err, expected); - assertMessageContains(err, this.now); - } - } - - private void assertMessageContains(AssertionError error, String expected) { - assertThat(error.getMessage()) - .as("Failure message should contain [" + expected + "], actual is [" + error.getMessage() + "]") - .contains(expected); + WebTestClient.ResponseSpec spec = testClient.get().uri("/persons/1") + .header(IF_MODIFIED_SINCE, minuteAgo) + .exchange() + .expectStatus().isOk(); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.accept(spec)) + .withMessageContainingAll("Response header '" + LAST_MODIFIED + "'", expected, this.now); } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/JsonPathAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/JsonPathAssertionTests.java index efcd967bc298..8c4b78669237 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/JsonPathAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/JsonPathAssertionTests.java @@ -64,12 +64,12 @@ public void exists() { client.get().uri("/music/people") .exchange() .expectBody() - .jsonPath(composerByName, "Johann Sebastian Bach").exists() - .jsonPath(composerByName, "Johannes Brahms").exists() - .jsonPath(composerByName, "Edvard Grieg").exists() - .jsonPath(composerByName, "Robert Schumann").exists() - .jsonPath(performerByName, "Vladimir Ashkenazy").exists() - .jsonPath(performerByName, "Yehudi Menuhin").exists() + .jsonPath(composerByName.formatted("Johann Sebastian Bach")).exists() + .jsonPath(composerByName.formatted("Johannes Brahms")).exists() + .jsonPath(composerByName.formatted("Edvard Grieg")).exists() + .jsonPath(composerByName.formatted("Robert Schumann")).exists() + .jsonPath(performerByName.formatted("Vladimir Ashkenazy")).exists() + .jsonPath(performerByName.formatted("Yehudi Menuhin")).exists() .jsonPath("$.composers[0]").exists() .jsonPath("$.composers[1]").exists() .jsonPath("$.composers[2]").exists() @@ -117,16 +117,13 @@ public void hamcrestMatcher() { @Test public void hamcrestMatcherWithParameterizedJsonPath() { - String composerName = "$.composers[%s].name"; - String performerName = "$.performers[%s].name"; - client.get().uri("/music/people") .exchange() .expectBody() - .jsonPath(composerName, 0).value(startsWith("Johann")) - .jsonPath(performerName, 0).value(endsWith("Ashkenazy")) - .jsonPath(performerName, 1).value(containsString("di Me")) - .jsonPath(composerName, 1).value(is(in(Arrays.asList("Johann Sebastian Bach", "Johannes Brahms")))); + .jsonPath("$.composers[0].name").value(startsWith("Johann")) + .jsonPath("$.performers[0].name").value(endsWith("Ashkenazy")) + .jsonPath("$.performers[1].name").value(containsString("di Me")) + .jsonPath("$.composers[1].name").value(is(in(Arrays.asList("Johann Sebastian Bach", "Johannes Brahms")))); } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/AsyncControllerJavaConfigTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/AsyncControllerJavaConfigTests.java index e6fea3f6067a..4a55f251fc12 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/AsyncControllerJavaConfigTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/AsyncControllerJavaConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -62,7 +62,7 @@ @ExtendWith(SpringExtension.class) @WebAppConfiguration @ContextHierarchy(@ContextConfiguration(classes = AsyncControllerJavaConfigTests.WebConfig.class)) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") public class AsyncControllerJavaConfigTests { @Autowired diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/JavaConfigTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/JavaConfigTests.java index 4ad707c7ea8e..06e7869c0235 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/JavaConfigTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/JavaConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -68,7 +68,7 @@ @ContextConfiguration(classes = RootConfig.class), @ContextConfiguration(classes = WebConfig.class) }) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") public class JavaConfigTests { @Autowired diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/WebAppResourceTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/WebAppResourceTests.java index 80638e94b94c..ddfd117360ff 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/WebAppResourceTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/WebAppResourceTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +49,7 @@ @ContextConfiguration("root-context.xml"), @ContextConfiguration("servlet-context.xml") }) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") public class WebAppResourceTests { @Autowired @@ -67,7 +67,7 @@ public void setup() { @Test public void resourceRequest() throws Exception { this.mockMvc.perform(get("/resources/Spring.js")) - .andExpect(content().contentType("application/javascript")) + .andExpect(content().contentType("text/javascript")) .andExpect(content().string(containsString("Spring={};"))); } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/XmlConfigTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/XmlConfigTests.java index 53a3d460ea19..9792f9197623 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/XmlConfigTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/XmlConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +50,7 @@ @ContextConfiguration("root-context.xml"), @ContextConfiguration("servlet-context.xml") }) -@DisabledInAotMode // @ContextHierarchy is not supported in AOT. +@DisabledInAotMode("@ContextHierarchy is not supported in AOT") public class XmlConfigTests { @Autowired diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/AsyncTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/AsyncTests.java index ec82a970b2bb..dd70f74d9b07 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/AsyncTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/AsyncTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.concurrent.Callable; import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; @@ -31,6 +32,8 @@ import org.springframework.test.web.Person; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.springframework.test.web.servlet.assertj.MvcTestResult; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; @@ -58,132 +61,220 @@ */ class AsyncTests { - private final AsyncController asyncController = new AsyncController(); + @Nested + class MockMvcTests { - private final MockMvc mockMvc = standaloneSetup(this.asyncController).build(); + private final MockMvc mockMvc = standaloneSetup(new AsyncController()).build(); + @Test + void callable() throws Exception { + MvcResult mvcResult = this.mockMvc.perform(get("/1").param("callable", "true")) + .andExpect(request().asyncStarted()) + .andExpect(request().asyncResult(equalTo(new Person("Joe")))) + .andExpect(request().asyncResult(new Person("Joe"))) + .andReturn(); - @Test - void callable() throws Exception { - MvcResult mvcResult = this.mockMvc.perform(get("/1").param("callable", "true")) - .andExpect(request().asyncStarted()) - .andExpect(request().asyncResult(equalTo(new Person("Joe")))) - .andExpect(request().asyncResult(new Person("Joe"))) - .andReturn(); + this.mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}")); + } - this.mockMvc.perform(asyncDispatch(mvcResult)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}")); - } + @Test + void streaming() throws Exception { + this.mockMvc.perform(get("/1").param("streaming", "true")) + .andExpect(request().asyncStarted()) + .andDo(MvcResult::getAsyncResult) // fetch async result similar to "asyncDispatch" builder + .andExpect(status().isOk()) + .andExpect(content().string("name=Joe")); + } - @Test - void streaming() throws Exception { - this.mockMvc.perform(get("/1").param("streaming", "true")) - .andExpect(request().asyncStarted()) - .andDo(MvcResult::getAsyncResult) // fetch async result similar to "asyncDispatch" builder - .andExpect(status().isOk()) - .andExpect(content().string("name=Joe")); - } + @Test + void streamingSlow() throws Exception { + this.mockMvc.perform(get("/1").param("streamingSlow", "true")) + .andExpect(request().asyncStarted()) + .andDo(MvcResult::getAsyncResult) + .andExpect(status().isOk()) + .andExpect(content().string("name=Joe&someBoolean=true")); + } - @Test - void streamingSlow() throws Exception { - this.mockMvc.perform(get("/1").param("streamingSlow", "true")) - .andExpect(request().asyncStarted()) - .andDo(MvcResult::getAsyncResult) - .andExpect(status().isOk()) - .andExpect(content().string("name=Joe&someBoolean=true")); - } + @Test + void streamingJson() throws Exception { + this.mockMvc.perform(get("/1").param("streamingJson", "true")) + .andExpect(request().asyncStarted()) + .andDo(MvcResult::getAsyncResult) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.5}")); + } - @Test - void streamingJson() throws Exception { - this.mockMvc.perform(get("/1").param("streamingJson", "true")) - .andExpect(request().asyncStarted()) - .andDo(MvcResult::getAsyncResult) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.5}")); - } + @Test + void deferredResult() throws Exception { + MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResult", "true")) + .andExpect(request().asyncStarted()) + .andReturn(); - @Test - void deferredResult() throws Exception { - MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResult", "true")) - .andExpect(request().asyncStarted()) - .andReturn(); + this.mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}")); + } - this.mockMvc.perform(asyncDispatch(mvcResult)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}")); - } + @Test + void deferredResultWithImmediateValue() throws Exception { + MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResultWithImmediateValue", "true")) + .andExpect(request().asyncStarted()) + .andExpect(request().asyncResult(new Person("Joe"))) + .andReturn(); + + this.mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}")); + } - @Test - void deferredResultWithImmediateValue() throws Exception { - MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResultWithImmediateValue", "true")) - .andExpect(request().asyncStarted()) - .andExpect(request().asyncResult(new Person("Joe"))) - .andReturn(); - - this.mockMvc.perform(asyncDispatch(mvcResult)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}")); - } + @Test // SPR-13079 + void deferredResultWithDelayedError() throws Exception { + MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResultWithDelayedError", "true")) + .andExpect(request().asyncStarted()) + .andReturn(); - @Test // SPR-13079 - void deferredResultWithDelayedError() throws Exception { - MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResultWithDelayedError", "true")) - .andExpect(request().asyncStarted()) - .andReturn(); + this.mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().is5xxServerError()) + .andExpect(content().string("Delayed Error")); + } - this.mockMvc.perform(asyncDispatch(mvcResult)) - .andExpect(status().is5xxServerError()) - .andExpect(content().string("Delayed Error")); - } + @Test + void listenableFuture() throws Exception { + MvcResult mvcResult = this.mockMvc.perform(get("/1").param("listenableFuture", "true")) + .andExpect(request().asyncStarted()) + .andReturn(); + + this.mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}")); + } - @Test - void listenableFuture() throws Exception { - MvcResult mvcResult = this.mockMvc.perform(get("/1").param("listenableFuture", "true")) - .andExpect(request().asyncStarted()) - .andReturn(); + @Test // SPR-12597 + void completableFutureWithImmediateValue() throws Exception { + MvcResult mvcResult = this.mockMvc.perform(get("/1").param("completableFutureWithImmediateValue", "true")) + .andExpect(request().asyncStarted()) + .andReturn(); - this.mockMvc.perform(asyncDispatch(mvcResult)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}")); - } + this.mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}")); + } + + @Test // SPR-12735 + void printAsyncResult() throws Exception { + StringWriter writer = new StringWriter(); + + MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResult", "true")) + .andDo(print(writer)) + .andExpect(request().asyncStarted()) + .andReturn(); - @Test // SPR-12597 - void completableFutureWithImmediateValue() throws Exception { - MvcResult mvcResult = this.mockMvc.perform(get("/1").param("completableFutureWithImmediateValue", "true")) - .andExpect(request().asyncStarted()) - .andReturn(); + assertThat(writer.toString()).contains("Async started = true"); + writer = new StringWriter(); - this.mockMvc.perform(asyncDispatch(mvcResult)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}")); + this.mockMvc.perform(asyncDispatch(mvcResult)) + .andDo(print(writer)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}")); + + assertThat(writer.toString()).contains("Async started = false"); + } } - @Test // SPR-12735 - void printAsyncResult() throws Exception { - StringWriter writer = new StringWriter(); + @Nested + class MockMvcTesterTests { + + private final MockMvcTester mockMvc = MockMvcTester.of(new AsyncController()); + + @Test + void callable() { + assertThat(mockMvc.get().uri("/1").param("callable", "true")) + .hasStatusOk() + .hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"); + } + + @Test + void streaming() { + assertThat(this.mockMvc.get().uri("/1").param("streaming", "true")) + .hasStatusOk().hasBodyTextEqualTo("name=Joe"); + } - MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResult", "true")) - .andDo(print(writer)) - .andExpect(request().asyncStarted()) - .andReturn(); + @Test + void streamingSlow() { + assertThat(this.mockMvc.get().uri("/1").param("streamingSlow", "true")) + .hasStatusOk().hasBodyTextEqualTo("name=Joe&someBoolean=true"); + } - assertThat(writer.toString()).contains("Async started = true"); - writer = new StringWriter(); + @Test + void streamingJson() { + assertThat(this.mockMvc.get().uri("/1").param("streamingJson", "true")) + .hasStatusOk() + .hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.5}"); + } - this.mockMvc.perform(asyncDispatch(mvcResult)) - .andDo(print(writer)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}")); + @Test + void deferredResult() { + assertThat(this.mockMvc.get().uri("/1").param("deferredResult", "true")) + .hasStatusOk() + .hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"); + } - assertThat(writer.toString()).contains("Async started = false"); + @Test + void deferredResultWithImmediateValue() { + assertThat(this.mockMvc.get().uri("/1").param("deferredResultWithImmediateValue", "true")) + .hasStatusOk() + .hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"); + } + + @Test // SPR-13079 + void deferredResultWithDelayedError() { + assertThat(this.mockMvc.get().uri("/1").param("deferredResultWithDelayedError", "true")) + .hasStatus5xxServerError().hasBodyTextEqualTo("Delayed Error"); + } + + @Test + void listenableFuture() { + assertThat(this.mockMvc.get().uri("/1").param("listenableFuture", "true")) + .hasStatusOk().hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"); + } + + @Test // SPR-12597 + void completableFutureWithImmediateValue() { + assertThat(this.mockMvc.get().uri("/1").param("completableFutureWithImmediateValue", "true")) + .hasStatusOk() + .hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"); + } + + @Test // SPR-12735 + void printAsyncResult() { + StringWriter asyncWriter = new StringWriter(); + + MvcTestResult result = this.mockMvc.get().uri("/1").param("deferredResult", "true").asyncExchange(); + assertThat(result).debug(asyncWriter).request().hasAsyncStarted(true); + assertThat(asyncWriter.toString()).contains("Async started = true"); + asyncWriter = new StringWriter(); // Reset + assertThat(this.mockMvc.perform(asyncDispatch(result.getMvcResult()))) + .debug(asyncWriter) + .hasStatusOk() + .hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"); + assertThat(asyncWriter.toString()).contains("Async started = false"); + } } @@ -243,7 +334,7 @@ DeferredResult getDeferredResultWithDelayedError() { } @RequestMapping(params = "listenableFuture") - @SuppressWarnings("deprecation") + @SuppressWarnings({"deprecation", "removal"}) org.springframework.util.concurrent.ListenableFuture getListenableFuture() { org.springframework.util.concurrent.ListenableFutureTask futureTask = new org.springframework.util.concurrent.ListenableFutureTask<>(() -> new Person("Joe")); diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/MultipartControllerTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/MultipartControllerTests.java index 8026ea48cf37..178f5ded8a65 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/MultipartControllerTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/MultipartControllerTests.java @@ -39,6 +39,7 @@ import org.springframework.stereotype.Controller; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder; +import org.springframework.util.CollectionUtils; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -271,7 +272,7 @@ public String processMultipartFileArray(@RequestParam(required = false) Multipar public String processMultipartFileList(@RequestParam(required = false) List file, @RequestPart(required = false) Map json) throws IOException { - if (file != null && !file.isEmpty()) { + if (!CollectionUtils.isEmpty(file)) { byte[] content = file.get(0).getBytes(); assertThat(file.get(1).getBytes()).isEqualTo(content); } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/RouterFunctionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/RouterFunctionTests.java new file mode 100644 index 000000000000..c653872c5a0a --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/RouterFunctionTests.java @@ -0,0 +1,167 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.standalone; + +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.servlet.function.RequestPredicates; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.routerFunctions; +import static org.springframework.web.servlet.function.RouterFunctions.route; +import static org.springframework.web.servlet.function.ServerResponse.ok; + +/** + * @author Arjen Poutsma + */ +public class RouterFunctionTests { + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + RouterFunction testRoute = testRoute(); + this.mockMvc = routerFunctions(testRoute).defaultResponseCharacterEncoding(UTF_8).build(); + } + + @Test + void json() throws Exception { + this.mockMvc + // We use a name containing an umlaut to test UTF-8 encoding for the request and the response. + .perform(get("/person/Jürgen").characterEncoding(UTF_8).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/json")) + .andExpect(content().encoding(UTF_8)) + .andExpect(content().string(containsString("Jürgen"))) + .andExpect(jsonPath("$.name").value("Jürgen")) + .andExpect(jsonPath("$.age").value(42)) + .andExpect(jsonPath("$.age").value(42.0f)) + .andExpect(jsonPath("$.age").value(equalTo(42))) + .andExpect(jsonPath("$.age").value(equalTo(42.0f), Float.class)) + .andExpect(jsonPath("$.age", equalTo(42))) + .andExpect(jsonPath("$.age", equalTo(42.0f), Float.class)); + } + + @Test + public void queryParameter() throws Exception { + this.mockMvc + .perform(get("/search?name=George").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/json")) + .andExpect(jsonPath("$.name").value("George")); + } + + @Nested + class AsyncTests { + + @Test + void completableFuture() throws Exception { + MvcResult mvcResult = mockMvc.perform(get("/async/completableFuture")) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string("{\"name\":\"Joe\",\"age\":0}")); + } + + @Test + void publisher() throws Exception { + MvcResult mvcResult = mockMvc.perform(get("/async/publisher")) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string("{\"name\":\"Joe\",\"age\":0}")); + + } + + } + + + private static RouterFunction testRoute() { + return route() + .GET("/person/{name}", request -> { + Person person = new Person(request.pathVariable("name")); + person.setAge(42); + return ok().body(person); + }) + .GET("/search", request -> { + String name = request.param("name").orElseThrow(NullPointerException::new); + Person person = new Person(name); + return ok().body(person); + }) + .path("/async", b -> b + .GET("/completableFuture", request -> { + CompletableFuture future = new CompletableFuture<>(); + future.complete(new Person("Joe")); + return ok().body(future); + }) + .GET("/publisher", request -> { + Mono mono = Mono.just(new Person("Joe")); + return ok().body(mono); + }) + ) + .route(RequestPredicates.all(), request -> ServerResponse.notFound().build()) + .build(); + } + + @SuppressWarnings("unused") + private static class Person { + + private final String name; + + private int age; + + public Person(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + public int getAge() { + return this.age; + } + + public void setAge(int age) { + this.age = age; + } + } +} 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 854ca7ad4b25..e8f7b71ecada 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-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 @@ import org.springframework.http.converter.json.SpringHandlerInstantiator; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.stereotype.Controller; +import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.support.WebApplicationContextUtils; @@ -133,12 +134,17 @@ void addFilterWithInitParams() throws ServletException { Filter filter = mock(Filter.class); ArgumentCaptor captor = ArgumentCaptor.forClass(FilterConfig.class); - MockMvcBuilders.standaloneSetup(new PersonController()) - .addFilter(filter, null, Map.of("p", "v"), EnumSet.of(DispatcherType.REQUEST), "/") + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PersonController()) + .addFilter(filter, "testFilter", Map.of("p", "v"), EnumSet.of(DispatcherType.REQUEST), "/") .build(); verify(filter, times(1)).init(captor.capture()); assertThat(captor.getValue().getInitParameter("p")).isEqualTo("v"); + + // gh-33252 + + assertThat(mockMvc.getDispatcherServlet().getServletContext().getFilterRegistrations()) + .hasSize(1).containsKey("testFilter"); } @Test // SPR-13375 diff --git a/spring-test/src/test/kotlin/org/springframework/test/web/servlet/MockMvcExtensionsTests.kt b/spring-test/src/test/kotlin/org/springframework/test/web/servlet/MockMvcExtensionsTests.kt index eea4afe9cf9d..e9fb0a8c2c3c 100644 --- a/spring-test/src/test/kotlin/org/springframework/test/web/servlet/MockMvcExtensionsTests.kt +++ b/spring-test/src/test/kotlin/org/springframework/test/web/servlet/MockMvcExtensionsTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,12 +23,15 @@ import org.hamcrest.CoreMatchers import org.junit.jupiter.api.Test import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus +import org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE import org.springframework.http.MediaType.APPLICATION_ATOM_XML import org.springframework.http.MediaType.APPLICATION_JSON import org.springframework.http.MediaType.APPLICATION_XML import org.springframework.http.MediaType.TEXT_PLAIN +import org.springframework.test.json.JsonCompareMode import org.springframework.test.web.Person import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.util.LinkedMultiValueMap import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -64,7 +67,7 @@ class MockMvcExtensionsTests { status { isOk() } content { contentType(APPLICATION_JSON) } jsonPath("$.name") { value("Lee") } - content { json("""{"someBoolean": false}""", false) } + content { json("""{"someBoolean": false}""", JsonCompareMode.LENIENT) } }.andDo { print() } @@ -130,7 +133,7 @@ class MockMvcExtensionsTests { status { isOk() } content { contentType(APPLICATION_JSON) } jsonPath("$.name") { value("Lee") } - content { json("""{"someBoolean": false}""", false) } + content { json("""{"someBoolean": false}""", JsonCompareMode.LENIENT) } }.andDo { print() } @@ -220,15 +223,62 @@ class MockMvcExtensionsTests { } @Test - fun queryParameter() { + fun queryParam() { val result = mockMvc.get("/") { - queryParam("foo", "bar") - queryParam("foo", "baz") + queryParam("foo", "bar", "baz") }.andReturn() assertThat(result.request.parameterMap["foo"]).containsExactly("bar", "baz") assertThat(result.request.queryString).isEqualTo("foo=bar&foo=baz") } + @Test + fun queryParams() { + val result = mockMvc.get("/") { + queryParams = LinkedMultiValueMap(mapOf("foo" to listOf("bar", "baz"))) + }.andReturn() + assertThat(result.request.parameterMap["foo"]).containsExactly("bar", "baz") + assertThat(result.request.queryString).isEqualTo("foo=bar&foo=baz") + } + + @Test + fun formField() { + val result = mockMvc.post("/person") { + formField("name", "foo", "bar") + formField("someDouble", "1.23") + }.andReturn() + assertThat(result.request.contentType).startsWith(APPLICATION_FORM_URLENCODED_VALUE) + assertThat(result.request.contentAsString).isEqualTo("name=foo&name=bar&someDouble=1.23") + } + + @Test + fun formFields() { + val result = mockMvc.post("/person") { + formFields = LinkedMultiValueMap(mapOf("name" to listOf("foo", "bar"), "someDouble" to listOf("1.23"))) + }.andReturn() + assertThat(result.request.contentType).startsWith(APPLICATION_FORM_URLENCODED_VALUE) + assertThat(result.request.contentAsString).isEqualTo("name=foo&name=bar&someDouble=1.23") + } + + @Test + fun sessionAttr() { + val result = mockMvc.post("/person") { + sessionAttr("name", "foo") + sessionAttr("someDouble", 1.23) + }.andReturn() + val session = result.request.session!! + assertThat(session.getAttribute("name")).isEqualTo("foo") + assertThat(session.getAttribute("someDouble")).isEqualTo(1.23) + } + + @Test + fun sessionAttrs() { + val result = mockMvc.post("/person") { + sessionAttrs = mapOf("name" to "foo", "someDouble" to 1.23) + }.andReturn() + val session = result.request.session!! + assertThat(session.getAttribute("name")).isEqualTo("foo") + assertThat(session.getAttribute("someDouble")).isEqualTo(1.23) + } @RestController private class PersonController { diff --git a/spring-test/src/test/resources/META-INF/web-resources/WEB-INF/layouts/tiles.xml b/spring-test/src/test/resources/META-INF/web-resources/WEB-INF/layouts/tiles.xml deleted file mode 100644 index 978b7c187d28..000000000000 --- a/spring-test/src/test/resources/META-INF/web-resources/WEB-INF/layouts/tiles.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/spring-test/src/test/resources/META-INF/web-resources/WEB-INF/views/tiles.xml b/spring-test/src/test/resources/META-INF/web-resources/WEB-INF/views/tiles.xml deleted file mode 100644 index 19b92e6ef2e3..000000000000 --- a/spring-test/src/test/resources/META-INF/web-resources/WEB-INF/views/tiles.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/spring-test/src/test/resources/org/springframework/test/context/jdbc/db1/data.sql b/spring-test/src/test/resources/org/springframework/test/context/jdbc/db1/data.sql new file mode 100644 index 000000000000..f6e5532bd514 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/context/jdbc/db1/data.sql @@ -0,0 +1 @@ +INSERT INTO user VALUES('Dilbert 1'); \ No newline at end of file diff --git a/spring-test/src/test/resources/org/springframework/test/context/jdbc/db2/data.sql b/spring-test/src/test/resources/org/springframework/test/context/jdbc/db2/data.sql new file mode 100644 index 000000000000..4369cf8cf2a7 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/context/jdbc/db2/data.sql @@ -0,0 +1 @@ +INSERT INTO user VALUES('Dilbert 2'); \ No newline at end of file diff --git a/spring-test/src/test/resources/org/springframework/test/json/different.json b/spring-test/src/test/resources/org/springframework/test/json/different.json new file mode 100644 index 000000000000..d641ea86e155 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/different.json @@ -0,0 +1,6 @@ +{ + "gnirps": [ + "boot", + "framework" + ] +} diff --git a/spring-test/src/test/resources/org/springframework/test/json/example.json b/spring-test/src/test/resources/org/springframework/test/json/example.json new file mode 100644 index 000000000000..cb218493f63a --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/example.json @@ -0,0 +1,4 @@ +{ + "name": "Spring", + "age": 123 +} diff --git a/spring-test/src/test/resources/org/springframework/test/json/lenient-same.json b/spring-test/src/test/resources/org/springframework/test/json/lenient-same.json new file mode 100644 index 000000000000..89367f7bf4a2 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/lenient-same.json @@ -0,0 +1,6 @@ +{ + "spring": [ + "framework", + "boot" + ] +} diff --git a/spring-test/src/test/resources/org/springframework/test/json/nulls.json b/spring-test/src/test/resources/org/springframework/test/json/nulls.json new file mode 100644 index 000000000000..1c1d3078254a --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/nulls.json @@ -0,0 +1,4 @@ +{ + "valuename": "spring", + "nullname": null +} diff --git a/spring-test/src/test/resources/org/springframework/test/json/simpsons.json b/spring-test/src/test/resources/org/springframework/test/json/simpsons.json new file mode 100644 index 000000000000..1117d6864e17 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/simpsons.json @@ -0,0 +1,36 @@ +{ + "familyMembers": [ + { + "name": "Homer" + }, + { + "name": "Marge" + }, + { + "name": "Bart" + }, + { + "name": "Lisa" + }, + { + "name": "Maggie" + } + ], + "indexedFamilyMembers": { + "father": { + "name": "Homer" + }, + "mother": { + "name": "Marge" + }, + "son": { + "name": "Bart" + }, + "daughter": { + "name": "Lisa" + }, + "baby": { + "name": "Maggie" + } + } +} diff --git a/spring-test/src/test/resources/org/springframework/test/json/source.json b/spring-test/src/test/resources/org/springframework/test/json/source.json new file mode 100644 index 000000000000..1b179b925301 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/source.json @@ -0,0 +1,6 @@ +{ + "spring": [ + "boot", + "framework" + ] +} diff --git a/spring-test/src/test/resources/org/springframework/test/json/types.json b/spring-test/src/test/resources/org/springframework/test/json/types.json new file mode 100644 index 000000000000..dd2dda3f1901 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/json/types.json @@ -0,0 +1,18 @@ +{ + "str": "foo", + "num": 5, + "pi": 3.1415926, + "bool": true, + "arr": [ + 42 + ], + "colorMap": { + "red": "rojo" + }, + "whitespace": " ", + "emptyString": "", + "emptyArray": [ + ], + "emptyMap": { + } +} diff --git a/spring-test/src/test/resources/org/springframework/test/web/servlet/assertj/message.json b/spring-test/src/test/resources/org/springframework/test/web/servlet/assertj/message.json new file mode 100644 index 000000000000..ff89222db782 --- /dev/null +++ b/spring-test/src/test/resources/org/springframework/test/web/servlet/assertj/message.json @@ -0,0 +1,3 @@ +{ + "message": "hello" +} \ No newline at end of file diff --git a/spring-test/src/test/webapp/WEB-INF/layouts/tiles.xml b/spring-test/src/test/webapp/WEB-INF/layouts/tiles.xml deleted file mode 100644 index d2444d74b1ac..000000000000 --- a/spring-test/src/test/webapp/WEB-INF/layouts/tiles.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/spring-test/src/test/webapp/WEB-INF/views/tiles.xml b/spring-test/src/test/webapp/WEB-INF/views/tiles.xml deleted file mode 100644 index c33229972913..000000000000 --- a/spring-test/src/test/webapp/WEB-INF/views/tiles.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/spring-tx/src/main/java/org/springframework/dao/DataAccessException.java b/spring-tx/src/main/java/org/springframework/dao/DataAccessException.java index 32479d9c02d4..9664399b13ab 100644 --- a/spring-tx/src/main/java/org/springframework/dao/DataAccessException.java +++ b/spring-tx/src/main/java/org/springframework/dao/DataAccessException.java @@ -27,7 +27,7 @@ * *

      This exception hierarchy aims to let user code find and handle the * kind of error encountered without knowing the details of the particular - * data access API in use (e.g. JDBC). Thus, it is possible to react to an + * data access API in use (for example, JDBC). Thus, it is possible to react to an * optimistic locking failure without knowing that JDBC is being used. * *

      As this class is a runtime exception, there is no need for user code diff --git a/spring-tx/src/main/java/org/springframework/dao/DataIntegrityViolationException.java b/spring-tx/src/main/java/org/springframework/dao/DataIntegrityViolationException.java index 0ec2d95ab9f7..a4044aa64830 100644 --- a/spring-tx/src/main/java/org/springframework/dao/DataIntegrityViolationException.java +++ b/spring-tx/src/main/java/org/springframework/dao/DataIntegrityViolationException.java @@ -25,7 +25,7 @@ * is not purely a relational concept; integrity constraints such * as unique primary keys are required by most database types. * - *

      Serves as a superclass for more specific exceptions, e.g. + *

      Serves as a superclass for more specific exceptions, for example, * {@link DuplicateKeyException}. However, it is generally * recommended to handle {@code DataIntegrityViolationException} * itself instead of relying on specific exception subclasses. diff --git a/spring-tx/src/main/java/org/springframework/dao/DataRetrievalFailureException.java b/spring-tx/src/main/java/org/springframework/dao/DataRetrievalFailureException.java index 6b36a34ab566..67e055adb3d1 100644 --- a/spring-tx/src/main/java/org/springframework/dao/DataRetrievalFailureException.java +++ b/spring-tx/src/main/java/org/springframework/dao/DataRetrievalFailureException.java @@ -19,7 +19,7 @@ import org.springframework.lang.Nullable; /** - * Exception thrown if certain expected data could not be retrieved, e.g. + * Exception thrown if certain expected data could not be retrieved, for example, * when looking up specific data via a known identifier. This exception * will be thrown either by O/R mapping tools or by DAO implementations. * diff --git a/spring-tx/src/main/java/org/springframework/dao/PessimisticLockingFailureException.java b/spring-tx/src/main/java/org/springframework/dao/PessimisticLockingFailureException.java index b0b5547679b2..82749d877856 100644 --- a/spring-tx/src/main/java/org/springframework/dao/PessimisticLockingFailureException.java +++ b/spring-tx/src/main/java/org/springframework/dao/PessimisticLockingFailureException.java @@ -23,7 +23,7 @@ * Thrown by Spring's SQLException translation mechanism * if a corresponding database error is encountered. * - *

      Serves as a superclass for more specific exceptions, e.g. + *

      Serves as a superclass for more specific exceptions, for example, * {@link CannotAcquireLockException}. However, it is generally * recommended to handle {@code PessimisticLockingFailureException} * itself instead of relying on specific exception subclasses. diff --git a/spring-tx/src/main/java/org/springframework/dao/annotation/PersistenceExceptionTranslationPostProcessor.java b/spring-tx/src/main/java/org/springframework/dao/annotation/PersistenceExceptionTranslationPostProcessor.java index 1c2bd4e7f683..6627cc080ffe 100644 --- a/spring-tx/src/main/java/org/springframework/dao/annotation/PersistenceExceptionTranslationPostProcessor.java +++ b/spring-tx/src/main/java/org/springframework/dao/annotation/PersistenceExceptionTranslationPostProcessor.java @@ -38,7 +38,7 @@ * PersistenceExceptionTranslator} interface, which are subsequently asked to translate * candidate exceptions. * - *

      All of Spring's applicable resource factories (e.g. + *

      All of Spring's applicable resource factories (for example, * {@link org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean}) * implement the {@code PersistenceExceptionTranslator} interface out of the box. * As a consequence, all that is usually needed to enable automatic exception diff --git a/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java b/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java index ce310ff46b67..6d610a946608 100644 --- a/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java +++ b/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -167,7 +167,11 @@ public static T requiredSingleResult(@Nullable Collection results) throws if (results.size() > 1) { throw new IncorrectResultSizeDataAccessException(1, results.size()); } - return results.iterator().next(); + T result = results.iterator().next(); + if (result == null) { + throw new TypeMismatchDataAccessException("Result value is null but no null value expected"); + } + return result; } /** @@ -235,7 +239,11 @@ public static T requiredUniqueResult(@Nullable Collection results) throws if (!CollectionUtils.hasUniqueObject(results)) { throw new IncorrectResultSizeDataAccessException(1, results.size()); } - return results.iterator().next(); + T result = results.iterator().next(); + if (result == null) { + throw new TypeMismatchDataAccessException("Result value is null but no null value expected"); + } + return result; } /** diff --git a/spring-tx/src/main/java/org/springframework/jca/endpoint/GenericMessageEndpointFactory.java b/spring-tx/src/main/java/org/springframework/jca/endpoint/GenericMessageEndpointFactory.java index 1da1e4d629dc..7ac7272c766e 100644 --- a/spring-tx/src/main/java/org/springframework/jca/endpoint/GenericMessageEndpointFactory.java +++ b/spring-tx/src/main/java/org/springframework/jca/endpoint/GenericMessageEndpointFactory.java @@ -34,7 +34,7 @@ * Generic implementation of the JCA 1.7 * {@link jakarta.resource.spi.endpoint.MessageEndpointFactory} interface, * providing transaction management capabilities for any kind of message - * listener object (e.g. {@link jakarta.jms.MessageListener} objects or + * listener object (for example, {@link jakarta.jms.MessageListener} objects or * {@link jakarta.resource.cci.MessageListener} objects). * *

      Uses AOP proxies for concrete endpoint instances, simply wrapping @@ -60,7 +60,7 @@ public class GenericMessageEndpointFactory extends AbstractMessageEndpointFactor /** * Specify the message listener object that the endpoint should expose - * (e.g. a {@link jakarta.jms.MessageListener} objects or + * (for example, a {@link jakarta.jms.MessageListener} objects or * {@link jakarta.resource.cci.MessageListener} implementation). */ public void setMessageListener(Object messageListener) { diff --git a/spring-tx/src/main/java/org/springframework/jca/endpoint/GenericMessageEndpointManager.java b/spring-tx/src/main/java/org/springframework/jca/endpoint/GenericMessageEndpointManager.java index 10e5295db6df..8ae0cbd2741f 100644 --- a/spring-tx/src/main/java/org/springframework/jca/endpoint/GenericMessageEndpointManager.java +++ b/spring-tx/src/main/java/org/springframework/jca/endpoint/GenericMessageEndpointManager.java @@ -87,7 +87,7 @@ *

      For a different target resource, the configuration would simply point to a * different ResourceAdapter and a different ActivationSpec object (which are * both specific to the resource provider), and possibly a different message - * listener (e.g. a CCI {@link jakarta.resource.cci.MessageListener} for a + * listener (for example, a CCI {@link jakarta.resource.cci.MessageListener} for a * resource adapter which is based on the JCA Common Client Interface). * *

      The asynchronous execution strategy can be customized through the @@ -123,7 +123,7 @@ * *

      Alternatively, check out your resource provider's ActivationSpec object, * which should support local transactions through a provider-specific config flag, - * e.g. ActiveMQActivationSpec's "useRAManagedTransaction" bean property. + * for example, ActiveMQActivationSpec's "useRAManagedTransaction" bean property. * *

        * <bean class="org.springframework.jca.endpoint.GenericMessageEndpointManager">
      diff --git a/spring-tx/src/main/java/org/springframework/jca/support/LocalConnectionFactoryBean.java b/spring-tx/src/main/java/org/springframework/jca/support/LocalConnectionFactoryBean.java
      index 3c4d213367ed..f7a0be1fe487 100644
      --- a/spring-tx/src/main/java/org/springframework/jca/support/LocalConnectionFactoryBean.java
      +++ b/spring-tx/src/main/java/org/springframework/jca/support/LocalConnectionFactoryBean.java
      @@ -56,7 +56,7 @@
        * of XA enlistment. You need to specify an XA-capable ConnectionManager in
        * order to make the connector interact with an XA transaction coordinator.
        * Alternatively, simply use the native local transaction facilities of the
      - * exposed API (e.g. CCI local transactions), or use a corresponding
      + * exposed API (for example, CCI local transactions), or use a corresponding
        * implementation of Spring's PlatformTransactionManager SPI to drive local
        * transactions.
        *
      diff --git a/spring-tx/src/main/java/org/springframework/transaction/PlatformTransactionManager.java b/spring-tx/src/main/java/org/springframework/transaction/PlatformTransactionManager.java
      index fb937bf42387..345b5bf27ff0 100644
      --- a/spring-tx/src/main/java/org/springframework/transaction/PlatformTransactionManager.java
      +++ b/spring-tx/src/main/java/org/springframework/transaction/PlatformTransactionManager.java
      @@ -34,7 +34,7 @@
        * 

      A classic implementation of this strategy interface is * {@link org.springframework.transaction.jta.JtaTransactionManager}. However, * in common single-resource scenarios, Spring's specific transaction managers - * for e.g. JDBC, JPA, JMS are preferred choices. + * for example, JDBC, JPA, JMS are preferred choices. * * @author Rod Johnson * @author Juergen Hoeller diff --git a/spring-tx/src/main/java/org/springframework/transaction/TransactionExecution.java b/spring-tx/src/main/java/org/springframework/transaction/TransactionExecution.java index f632c4d85e78..63b3da57fa48 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/TransactionExecution.java +++ b/spring-tx/src/main/java/org/springframework/transaction/TransactionExecution.java @@ -60,7 +60,7 @@ default boolean hasTransaction() { *

      This is primarily here for transaction manager state handling. * Prefer the use of {@link #hasTransaction()} for application purposes * since this is usually semantically appropriate. - *

      The "new" status can be transaction manager specific, e.g. returning + *

      The "new" status can be transaction manager specific, for example, returning * {@code true} for an actual nested transaction but potentially {@code false} * for a savepoint-based nested transaction scope if the savepoint management * is explicitly exposed (such as on {@link TransactionStatus}). A combined diff --git a/spring-tx/src/main/java/org/springframework/transaction/annotation/AbstractTransactionManagementConfiguration.java b/spring-tx/src/main/java/org/springframework/transaction/annotation/AbstractTransactionManagementConfiguration.java index 0bcfb3b7078b..178bd5640dde 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/annotation/AbstractTransactionManagementConfiguration.java +++ b/spring-tx/src/main/java/org/springframework/transaction/annotation/AbstractTransactionManagementConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +30,8 @@ import org.springframework.transaction.TransactionManager; import org.springframework.transaction.config.TransactionManagementConfigUtils; import org.springframework.transaction.event.TransactionalEventListenerFactory; +import org.springframework.transaction.interceptor.RollbackRuleAttribute; +import org.springframework.transaction.interceptor.TransactionAttributeSource; import org.springframework.util.CollectionUtils; /** @@ -38,6 +40,7 @@ * * @author Chris Beams * @author Stephane Nicoll + * @author Juergen Hoeller * @since 3.1 * @see EnableTransactionManagement */ @@ -77,6 +80,18 @@ void setConfigurers(Collection configurers) { } + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public TransactionAttributeSource transactionAttributeSource() { + // Accept protected @Transactional methods on CGLIB proxies, as of 6.0 + AnnotationTransactionAttributeSource tas = new AnnotationTransactionAttributeSource(false); + // Apply default rollback rule, as of 6.2 + if (this.enableTx != null && this.enableTx.getEnum("rollbackOn") == RollbackOn.ALL_EXCEPTIONS) { + tas.addDefaultRollbackRule(RollbackRuleAttribute.ROLLBACK_ON_ALL_EXCEPTIONS); + } + return tas; + } + @Bean(name = TransactionManagementConfigUtils.TRANSACTIONAL_EVENT_LISTENER_FACTORY_BEAN_NAME) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public static TransactionalEventListenerFactory transactionalEventListenerFactory() { diff --git a/spring-tx/src/main/java/org/springframework/transaction/annotation/AnnotationTransactionAttributeSource.java b/spring-tx/src/main/java/org/springframework/transaction/annotation/AnnotationTransactionAttributeSource.java index 4b6b81b7085c..bc89a105111d 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/annotation/AnnotationTransactionAttributeSource.java +++ b/spring-tx/src/main/java/org/springframework/transaction/annotation/AnnotationTransactionAttributeSource.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,27 +19,30 @@ import java.io.Serializable; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; -import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashSet; import java.util.Set; import org.springframework.lang.Nullable; import org.springframework.transaction.interceptor.AbstractFallbackTransactionAttributeSource; +import org.springframework.transaction.interceptor.RollbackRuleAttribute; +import org.springframework.transaction.interceptor.RuleBasedTransactionAttribute; import org.springframework.transaction.interceptor.TransactionAttribute; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; /** * Implementation of the * {@link org.springframework.transaction.interceptor.TransactionAttributeSource} - * interface for working with transaction metadata in JDK 1.5+ annotation format. + * interface for working with transaction metadata from annotations. * - *

      This class reads Spring's JDK 1.5+ {@link Transactional} annotation and + *

      This class reads Spring's {@link Transactional @Transactional} annotation and * exposes corresponding transaction attributes to Spring's transaction infrastructure. - * Also supports JTA 1.2's {@link jakarta.transaction.Transactional} and EJB3's + * Also supports JTA's {@link jakarta.transaction.Transactional} and EJB's * {@link jakarta.ejb.TransactionAttribute} annotation (if present). - * This class may also serve as base class for a custom TransactionAttributeSource, + * + *

      This class may also serve as base class for a custom TransactionAttributeSource, * or get customized through {@link TransactionAnnotationParser} strategies. * * @author Colin Sampaleanu @@ -56,20 +59,23 @@ public class AnnotationTransactionAttributeSource extends AbstractFallbackTransactionAttributeSource implements Serializable { - private static final boolean jta12Present; + private static final boolean jtaPresent; private static final boolean ejb3Present; static { ClassLoader classLoader = AnnotationTransactionAttributeSource.class.getClassLoader(); - jta12Present = ClassUtils.isPresent("jakarta.transaction.Transactional", classLoader); + jtaPresent = ClassUtils.isPresent("jakarta.transaction.Transactional", classLoader); ejb3Present = ClassUtils.isPresent("jakarta.ejb.TransactionAttribute", classLoader); } - private final boolean publicMethodsOnly; - private final Set annotationParsers; + private boolean publicMethodsOnly = true; + + @Nullable + private Set defaultRollbackRules; + /** * Create a default AnnotationTransactionAttributeSource, supporting @@ -77,7 +83,19 @@ public class AnnotationTransactionAttributeSource extends AbstractFallbackTransa * or the EJB3 {@link jakarta.ejb.TransactionAttribute} annotation. */ public AnnotationTransactionAttributeSource() { - this(true); + if (jtaPresent || ejb3Present) { + this.annotationParsers = CollectionUtils.newLinkedHashSet(3); + this.annotationParsers.add(new SpringTransactionAnnotationParser()); + if (jtaPresent) { + this.annotationParsers.add(new JtaTransactionAnnotationParser()); + } + if (ejb3Present) { + this.annotationParsers.add(new Ejb3TransactionAnnotationParser()); + } + } + else { + this.annotationParsers = Collections.singleton(new SpringTransactionAnnotationParser()); + } } /** @@ -88,22 +106,11 @@ public AnnotationTransactionAttributeSource() { * the {@code Transactional} annotation only (typically for use * with proxy-based AOP), or protected/private methods as well * (typically used with AspectJ class weaving) + * @see #setPublicMethodsOnly */ public AnnotationTransactionAttributeSource(boolean publicMethodsOnly) { + this(); this.publicMethodsOnly = publicMethodsOnly; - if (jta12Present || ejb3Present) { - this.annotationParsers = new LinkedHashSet<>(4); - this.annotationParsers.add(new SpringTransactionAnnotationParser()); - if (jta12Present) { - this.annotationParsers.add(new JtaTransactionAnnotationParser()); - } - if (ejb3Present) { - this.annotationParsers.add(new Ejb3TransactionAnnotationParser()); - } - } - else { - this.annotationParsers = Collections.singleton(new SpringTransactionAnnotationParser()); - } } /** @@ -111,7 +118,6 @@ public AnnotationTransactionAttributeSource(boolean publicMethodsOnly) { * @param annotationParser the TransactionAnnotationParser to use */ public AnnotationTransactionAttributeSource(TransactionAnnotationParser annotationParser) { - this.publicMethodsOnly = true; Assert.notNull(annotationParser, "TransactionAnnotationParser must not be null"); this.annotationParsers = Collections.singleton(annotationParser); } @@ -121,19 +127,40 @@ public AnnotationTransactionAttributeSource(TransactionAnnotationParser annotati * @param annotationParsers the TransactionAnnotationParsers to use */ public AnnotationTransactionAttributeSource(TransactionAnnotationParser... annotationParsers) { - this.publicMethodsOnly = true; Assert.notEmpty(annotationParsers, "At least one TransactionAnnotationParser needs to be specified"); - this.annotationParsers = new LinkedHashSet<>(Arrays.asList(annotationParsers)); + this.annotationParsers = Set.of(annotationParsers); } + /** - * Create a custom AnnotationTransactionAttributeSource. - * @param annotationParsers the TransactionAnnotationParsers to use + * Set whether transactional methods are expected to be public. + *

      The default is {@code true}. + * @since 6.2 + * @see #AnnotationTransactionAttributeSource(boolean) */ - public AnnotationTransactionAttributeSource(Set annotationParsers) { - this.publicMethodsOnly = true; - Assert.notEmpty(annotationParsers, "At least one TransactionAnnotationParser needs to be specified"); - this.annotationParsers = annotationParsers; + public void setPublicMethodsOnly(boolean publicMethodsOnly) { + this.publicMethodsOnly = publicMethodsOnly; + } + + /** + * Add a default rollback rule, to be applied to all rule-based + * transaction attributes returned by this source. + *

      By default, a rollback will be triggered on unchecked exceptions + * but not on checked exceptions. A default rule may override this + * while still respecting any custom rules in the transaction attribute. + * @param rollbackRule a rollback rule overriding the default behavior, + * for example, {@link RollbackRuleAttribute#ROLLBACK_ON_ALL_EXCEPTIONS} + * @since 6.2 + * @see RuleBasedTransactionAttribute#getRollbackRules() + * @see EnableTransactionManagement#rollbackOn() + * @see Transactional#rollbackFor() + * @see Transactional#noRollbackFor() + */ + public void addDefaultRollbackRule(RollbackRuleAttribute rollbackRule) { + if (this.defaultRollbackRules == null) { + this.defaultRollbackRules = new LinkedHashSet<>(); + } + this.defaultRollbackRules.add(rollbackRule); } @@ -174,6 +201,9 @@ protected TransactionAttribute determineTransactionAttribute(AnnotatedElement el for (TransactionAnnotationParser parser : this.annotationParsers) { TransactionAttribute attr = parser.parseTransactionAnnotation(element); if (attr != null) { + if (this.defaultRollbackRules != null && attr instanceof RuleBasedTransactionAttribute ruleAttr) { + ruleAttr.getRollbackRules().addAll(this.defaultRollbackRules); + } return attr; } } @@ -182,6 +212,7 @@ protected TransactionAttribute determineTransactionAttribute(AnnotatedElement el /** * By default, only public methods can be made transactional. + * @see #setPublicMethodsOnly */ @Override protected boolean allowPublicMethodsOnly() { diff --git a/spring-tx/src/main/java/org/springframework/transaction/annotation/EnableTransactionManagement.java b/spring-tx/src/main/java/org/springframework/transaction/annotation/EnableTransactionManagement.java index d39fe8fa7e6e..8c83e5c06506 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/annotation/EnableTransactionManagement.java +++ b/spring-tx/src/main/java/org/springframework/transaction/annotation/EnableTransactionManagement.java @@ -172,7 +172,7 @@ * {@code @Transactional}. For example, other beans marked with Spring's * {@code @Async} annotation will be upgraded to subclass proxying at the same * time. This approach has no negative impact in practice unless one is explicitly - * expecting one type of proxy vs another, e.g. in tests. + * expecting one type of proxy vs another, for example, in tests. */ boolean proxyTargetClass() default false; @@ -195,4 +195,25 @@ */ int order() default Ordered.LOWEST_PRECEDENCE; + /** + * Indicate the rollback behavior for rule-based transactions without + * custom rollback rules: default is rollback on unchecked exception, + * this can be switched to rollback on any exception (including checked). + *

      Note that transaction-specific rollback rules override the default + * behavior but retain the chosen default for unspecified exceptions. + * This is the case for Spring's {@link Transactional} as well as JTA's + * {@link jakarta.transaction.Transactional} when used with Spring here. + *

      Unless you rely on EJB-style business exceptions with commit behavior, + * it is advisable to switch to {@link RollbackOn#ALL_EXCEPTIONS} for a + * consistent rollback even in case of a (potentially accidental) checked + * exception. Also, it is advisable to make that switch for Kotlin-based + * applications where there is no enforcement of checked exceptions at all. + * @since 6.2 + * @see Transactional#rollbackFor() + * @see Transactional#noRollbackFor() + * @see jakarta.transaction.Transactional#rollbackOn() + * @see jakarta.transaction.Transactional#dontRollbackOn() + */ + RollbackOn rollbackOn() default RollbackOn.RUNTIME_EXCEPTIONS; + } diff --git a/spring-tx/src/main/java/org/springframework/transaction/annotation/ProxyTransactionManagementConfiguration.java b/spring-tx/src/main/java/org/springframework/transaction/annotation/ProxyTransactionManagementConfiguration.java index f2128f3acaf8..3adbbf511ea7 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/annotation/ProxyTransactionManagementConfiguration.java +++ b/spring-tx/src/main/java/org/springframework/transaction/annotation/ProxyTransactionManagementConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author 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,13 +55,6 @@ public BeanFactoryTransactionAttributeSourceAdvisor transactionAdvisor( return advisor; } - @Bean - @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - public TransactionAttributeSource transactionAttributeSource() { - // Accept protected @Transactional methods on CGLIB proxies, as of 6.0. - return new AnnotationTransactionAttributeSource(false); - } - @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public TransactionInterceptor transactionInterceptor(TransactionAttributeSource transactionAttributeSource) { diff --git a/spring-tx/src/main/java/org/springframework/transaction/annotation/RestrictedTransactionalEventListenerFactory.java b/spring-tx/src/main/java/org/springframework/transaction/annotation/RestrictedTransactionalEventListenerFactory.java index 6dcda47fa207..e1473eb411e1 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/annotation/RestrictedTransactionalEventListenerFactory.java +++ b/spring-tx/src/main/java/org/springframework/transaction/annotation/RestrictedTransactionalEventListenerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,6 +38,11 @@ public class RestrictedTransactionalEventListenerFactory extends TransactionalEv @Override public ApplicationListener createApplicationListener(String beanName, Class type, Method method) { Transactional txAnn = AnnotatedElementUtils.findMergedAnnotation(method, Transactional.class); + + if (txAnn == null) { + txAnn = AnnotatedElementUtils.findMergedAnnotation(type, Transactional.class); + } + if (txAnn != null) { Propagation propagation = txAnn.propagation(); if (propagation != Propagation.REQUIRES_NEW && propagation != Propagation.NOT_SUPPORTED) { diff --git a/spring-tx/src/main/java/org/springframework/transaction/annotation/RollbackOn.java b/spring-tx/src/main/java/org/springframework/transaction/annotation/RollbackOn.java new file mode 100644 index 000000000000..46d57c939463 --- /dev/null +++ b/spring-tx/src/main/java/org/springframework/transaction/annotation/RollbackOn.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.transaction.annotation; + +/** + * An enum for global rollback-on behavior. + * + *

      Note that the default behavior matches the traditional behavior in + * EJB CMT and JTA, with the latter having rollback rules similar to Spring. + * A global switch to trigger a rollback on any exception affects Spring's + * {@link Transactional} as well as {@link jakarta.transaction.Transactional} + * but leaves the non-rule-based {@link jakarta.ejb.TransactionAttribute} as-is. + * + * @author Juergen Hoeller + * @since 6.2 + * @see EnableTransactionManagement#rollbackOn() + * @see org.springframework.transaction.interceptor.RuleBasedTransactionAttribute + */ +public enum RollbackOn { + + /** + * The default rollback-on behavior: rollback on + * {@link RuntimeException RuntimeExceptions} as well as {@link Error Errors}. + * @see org.springframework.transaction.interceptor.RollbackRuleAttribute#ROLLBACK_ON_RUNTIME_EXCEPTIONS + */ + RUNTIME_EXCEPTIONS, + + /** + * The alternative mode: rollback on all exceptions, including any checked + * {@link Exception}. + * @see org.springframework.transaction.interceptor.RollbackRuleAttribute#ROLLBACK_ON_ALL_EXCEPTIONS + */ + ALL_EXCEPTIONS + +} diff --git a/spring-tx/src/main/java/org/springframework/transaction/annotation/TransactionAnnotationParser.java b/spring-tx/src/main/java/org/springframework/transaction/annotation/TransactionAnnotationParser.java index 0e9ee83a568d..8aa5f2b28028 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/annotation/TransactionAnnotationParser.java +++ b/spring-tx/src/main/java/org/springframework/transaction/annotation/TransactionAnnotationParser.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +60,8 @@ default boolean isCandidateClass(Class targetClass) { * based on an annotation type understood by this parser. *

      This essentially parses a known transaction annotation into Spring's metadata * attribute class. Returns {@code null} if the method/class is not transactional. + *

      The returned attribute will typically (but not necessarily) be of type + * {@link org.springframework.transaction.interceptor.RuleBasedTransactionAttribute}. * @param element the annotated method or class * @return the configured transaction attribute, or {@code null} if none found * @see AnnotationTransactionAttributeSource#determineTransactionAttribute diff --git a/spring-tx/src/main/java/org/springframework/transaction/annotation/TransactionBeanRegistrationAotProcessor.java b/spring-tx/src/main/java/org/springframework/transaction/annotation/TransactionBeanRegistrationAotProcessor.java index e8abaa78ebfb..93f8114cf729 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/annotation/TransactionBeanRegistrationAotProcessor.java +++ b/spring-tx/src/main/java/org/springframework/transaction/annotation/TransactionBeanRegistrationAotProcessor.java @@ -83,9 +83,6 @@ public AotContribution(Class beanClass) { public void applyTo(GenerationContext generationContext, BeanRegistrationCode beanRegistrationCode) { RuntimeHints runtimeHints = generationContext.getRuntimeHints(); Class[] proxyInterfaces = ClassUtils.getAllInterfacesForClass(this.beanClass); - if (proxyInterfaces.length == 0) { - return; - } for (Class proxyInterface : proxyInterfaces) { runtimeHints.reflection().registerType(proxyInterface, MemberCategory.INVOKE_DECLARED_METHODS); } diff --git a/spring-tx/src/main/java/org/springframework/transaction/annotation/Transactional.java b/spring-tx/src/main/java/org/springframework/transaction/annotation/Transactional.java index 8ca962a0590a..956dfe60ea12 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/annotation/Transactional.java +++ b/spring-tx/src/main/java/org/springframework/transaction/annotation/Transactional.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -108,7 +108,7 @@ *

      Note: When configured with a {@code ReactiveTransactionManager}, all * transaction-demarcated methods are expected to return a reactive pipeline. * Void methods or regular return types need to be associated with a regular - * {@code PlatformTransactionManager}, e.g. through {@link #transactionManager()}. + * {@code PlatformTransactionManager}, for example, through {@link #transactionManager()}. * * @author Colin Sampaleanu * @author Juergen Hoeller @@ -139,6 +139,14 @@ * qualifier value (or the bean name) of a specific * {@link org.springframework.transaction.TransactionManager TransactionManager} * bean definition. + *

      Alternatively, as of 6.2, a type-level bean qualifier annotation with a + * {@link org.springframework.beans.factory.annotation.Qualifier#value() qualifier value} + * is also taken into account. If it matches the qualifier value (or bean name) + * of a specific transaction manager, that transaction manager is going to be used + * for transaction definitions without a specific qualifier on this attribute here. + * Such a type-level qualifier can be declared on the concrete class, applying + * to transaction definitions from a base class as well, effectively overriding + * the default transaction manager choice for any unqualified base class methods. * @since 4.2 * @see #value * @see org.springframework.transaction.PlatformTransactionManager @@ -197,7 +205,7 @@ *

      Exclusively designed for use with {@link Propagation#REQUIRED} or * {@link Propagation#REQUIRES_NEW} since it only applies to newly started * transactions. - * @return the timeout in seconds as a String value, e.g. a placeholder + * @return the timeout in seconds as a String value, for example, a placeholder * @since 5.3 * @see org.springframework.transaction.interceptor.TransactionAttribute#getTimeout() */ 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 0ac4138bf3b6..a61b99abd735 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-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 +49,6 @@ public class TransactionalApplicationListenerMethodAdapter extends ApplicationLi private final TransactionPhase transactionPhase; - private final boolean fallbackExecution; - private final List callbacks = new CopyOnWriteArrayList<>(); @@ -68,7 +66,6 @@ public TransactionalApplicationListenerMethodAdapter(String beanName, Class t throw new IllegalStateException("No TransactionalEventListener annotation found on method: " + method); } this.transactionPhase = eventAnn.phase(); - this.fallbackExecution = eventAnn.fallbackExecution(); } @@ -91,7 +88,7 @@ public void onApplicationEvent(ApplicationEvent event) { logger.debug("Registered transaction synchronization for " + event); } } - else if (this.fallbackExecution) { + else if (isDefaultExecution()) { if (getTransactionPhase() == TransactionPhase.AFTER_ROLLBACK && logger.isWarnEnabled()) { logger.warn("Processing " + event + " as a fallback execution on AFTER_ROLLBACK phase"); } 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 3ade90efc80d..0897270da5c7 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-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -77,11 +77,6 @@ */ TransactionPhase phase() default TransactionPhase.AFTER_COMMIT; - /** - * Whether the event should be handled if no transaction is running. - */ - boolean fallbackExecution() default false; - /** * Alias for {@link #classes}. */ @@ -107,9 +102,16 @@ @AliasFor(annotation = EventListener.class, attribute = "condition") String condition() default ""; + /** + * Whether the event should be handled if no transaction is running. + * @see EventListener#defaultExecution() + */ + @AliasFor(annotation = EventListener.class, attribute = "defaultExecution") + boolean fallbackExecution() default false; + /** * An optional identifier for the listener, defaulting to the fully-qualified - * signature of the declaring method (e.g. "mypackage.MyClass.myMethod()"). + * signature of the declaring method (for example, "mypackage.MyClass.myMethod()"). * @since 5.3 * @see EventListener#id * @see TransactionalApplicationListener#getListenerId() diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/AbstractFallbackTransactionAttributeSource.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/AbstractFallbackTransactionAttributeSource.java index 6c9a243d4d7e..829680eae2fc 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/AbstractFallbackTransactionAttributeSource.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/AbstractFallbackTransactionAttributeSource.java @@ -29,6 +29,7 @@ import org.springframework.core.MethodClassKey; import org.springframework.lang.Nullable; import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; import org.springframework.util.StringValueResolver; /** @@ -86,18 +87,31 @@ public void setEmbeddedValueResolver(StringValueResolver resolver) { } + @Override + public boolean hasTransactionAttribute(Method method, @Nullable Class targetClass) { + return (getTransactionAttribute(method, targetClass, false) != null); + } + + @Override + @Nullable + public TransactionAttribute getTransactionAttribute(Method method, @Nullable Class targetClass) { + return getTransactionAttribute(method, targetClass, true); + } + /** * Determine the transaction attribute for this method invocation. *

      Defaults to the class's transaction attribute if no method attribute is found. * @param method the method for the current invocation (never {@code null}) * @param targetClass the target class for this invocation (can be {@code null}) + * @param cacheNull whether {@code null} results should be cached as well * @return a TransactionAttribute for this method, or {@code null} if the method * is not transactional */ - @Override @Nullable - public TransactionAttribute getTransactionAttribute(Method method, @Nullable Class targetClass) { - if (method.getDeclaringClass() == Object.class) { + private TransactionAttribute getTransactionAttribute( + Method method, @Nullable Class targetClass, boolean cacheNull) { + + if (ReflectionUtils.isObjectMethod(method)) { return null; } @@ -120,7 +134,7 @@ public TransactionAttribute getTransactionAttribute(Method method, @Nullable Cla } this.attributeCache.put(cacheKey, txAttr); } - else { + else if (cacheNull) { this.attributeCache.put(cacheKey, NULL_TRANSACTION_ATTRIBUTE); } return txAttr; diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/CompositeTransactionAttributeSource.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/CompositeTransactionAttributeSource.java index 8b735f0d4d4a..d547829066d4 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/CompositeTransactionAttributeSource.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/CompositeTransactionAttributeSource.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2024 the original author 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,16 @@ public boolean isCandidateClass(Class targetClass) { return false; } + @Override + public boolean hasTransactionAttribute(Method method, @Nullable Class targetClass) { + for (TransactionAttributeSource source : this.transactionAttributeSources) { + if (source.hasTransactionAttribute(method, targetClass)) { + return true; + } + } + return false; + } + @Override @Nullable public TransactionAttribute getTransactionAttribute(Method method, @Nullable Class targetClass) { diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/DefaultTransactionAttribute.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/DefaultTransactionAttribute.java index 5d900f79163d..c373271c28c1 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/DefaultTransactionAttribute.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/DefaultTransactionAttribute.java @@ -18,11 +18,11 @@ import java.util.Collection; import java.util.Collections; -import java.util.LinkedHashSet; import java.util.Set; import org.springframework.lang.Nullable; import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.util.StringValueResolver; @@ -60,7 +60,6 @@ public class DefaultTransactionAttribute extends DefaultTransactionDefinition im * @see #setName */ public DefaultTransactionAttribute() { - super(); } /** @@ -91,7 +90,7 @@ public DefaultTransactionAttribute(int propagationBehavior) { /** * Set a descriptor for this transaction attribute, - * e.g. indicating where the attribute is applying. + * for example, indicating where the attribute is applying. * @since 4.3.4 */ public void setDescriptor(@Nullable String descriptor) { @@ -216,7 +215,7 @@ public void resolveAttributeStrings(@Nullable StringValueResolver resolver) { if (this.qualifier != null) { this.qualifier = resolver.resolveStringValue(this.qualifier); } - Set resolvedLabels = new LinkedHashSet<>(this.labels.size()); + Set resolvedLabels = CollectionUtils.newLinkedHashSet(this.labels.size()); for (String label : this.labels) { resolvedLabels.add(resolver.resolveStringValue(label)); } diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/MethodMapTransactionAttributeSource.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/MethodMapTransactionAttributeSource.java index 4e102106b36b..a4fe2cac71a1 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/MethodMapTransactionAttributeSource.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/MethodMapTransactionAttributeSource.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 +73,8 @@ public class MethodMapTransactionAttributeSource /** - * Set a name/attribute map, consisting of "FQCN.method" method names - * (e.g. "com.mycompany.mycode.MyClass.myMethod") and + * Set a name/attribute map, consisting of "{@code .}" + * method names (for example, "com.mycompany.mycode.MyClass.myMethod") and * {@link TransactionAttribute} instances (or Strings to be converted * to {@code TransactionAttribute} instances). *

      Intended for configuration via setter injection, typically within @@ -134,7 +134,8 @@ public void addTransactionalMethod(String name, TransactionAttribute attr) { Assert.notNull(name, "Name must not be null"); int lastDotIndex = name.lastIndexOf('.'); if (lastDotIndex == -1) { - throw new IllegalArgumentException("'" + name + "' is not a valid method name: format is FQN.methodName"); + throw new IllegalArgumentException( + "'" + name + "' is not a valid method name: format is ."); } String className = name.substring(0, lastDotIndex); String methodName = name.substring(lastDotIndex + 1); diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/NameMatchTransactionAttributeSource.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/NameMatchTransactionAttributeSource.java index 4d9ecbd42fc3..7eb89423037a 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/NameMatchTransactionAttributeSource.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/NameMatchTransactionAttributeSource.java @@ -62,7 +62,7 @@ public class NameMatchTransactionAttributeSource /** * Set a name/attribute map, consisting of method names - * (e.g. "myMethod") and {@link TransactionAttribute} instances. + * (for example, "myMethod") and {@link TransactionAttribute} instances. * @see #setProperties * @see TransactionAttribute */ diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/RollbackRuleAttribute.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/RollbackRuleAttribute.java index 0762a9ae7973..2f9e23296d1c 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/RollbackRuleAttribute.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/RollbackRuleAttribute.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,14 @@ public class RollbackRuleAttribute implements Serializable{ public static final RollbackRuleAttribute ROLLBACK_ON_RUNTIME_EXCEPTIONS = new RollbackRuleAttribute(RuntimeException.class); + /** + * The {@linkplain RollbackRuleAttribute rollback rule} for all + * {@link Exception Exceptions}, including checked exceptions. + * @since 6.2 + */ + public static final RollbackRuleAttribute ROLLBACK_ON_ALL_EXCEPTIONS = + new RollbackRuleAttribute(Exception.class); + /** * Exception pattern: used when searching for matches in a thrown exception's diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/RuleBasedTransactionAttribute.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/RuleBasedTransactionAttribute.java index 12a20d13e283..f1425fc79809 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/RuleBasedTransactionAttribute.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/RuleBasedTransactionAttribute.java @@ -60,7 +60,6 @@ public class RuleBasedTransactionAttribute extends DefaultTransactionAttribute i * @see #setRollbackRules */ public RuleBasedTransactionAttribute() { - super(); } /** diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAspectSupport.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAspectSupport.java index cbad1486d7a8..89dd57102573 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAspectSupport.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAspectSupport.java @@ -31,6 +31,7 @@ import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.annotation.BeanFactoryAnnotationUtils; import org.springframework.core.KotlinDetector; import org.springframework.core.MethodParameter; @@ -67,7 +68,7 @@ * *

      Uses the Strategy design pattern. A {@link PlatformTransactionManager} or * {@link ReactiveTransactionManager} implementation will perform the actual transaction - * management, and a {@link TransactionAttributeSource} (e.g. annotation-based) is used + * management, and a {@link TransactionAttributeSource} (for example, annotation-based) is used * for determining transaction definitions for a particular class or method. * *

      A transaction aspect is serializable if its {@code TransactionManager} and @@ -115,7 +116,7 @@ public abstract class TransactionAspectSupport implements BeanFactoryAware, Init /** * Holder to support the {@code currentTransactionStatus()} method, * and to support communication between different cooperating advices - * (e.g. before and after advice) if the aspect involves more than a + * (for example, before and after advice) if the aspect involves more than a * single method (as will be the case for around advice). */ private static final ThreadLocal transactionInfoHolder = @@ -244,7 +245,7 @@ public TransactionManager getTransactionManager() { /** * Set properties with method names as keys and transaction attribute * descriptors (parsed via TransactionAttributeEditor) as values: - * e.g. key = "myMethod", value = "PROPAGATION_REQUIRED,readOnly". + * for example, key = "myMethod", value = "PROPAGATION_REQUIRED,readOnly". *

      Note: Method names are always applied to the target class, * no matter if defined in an interface or the class itself. *

      Internally, a NameMatchTransactionAttributeSource will be @@ -344,7 +345,7 @@ protected Object invokeWithinTransaction(Method method, @Nullable Class targe // If the transaction attribute is null, the method is non-transactional. TransactionAttributeSource tas = getTransactionAttributeSource(); final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null); - final TransactionManager tm = determineTransactionManager(txAttr); + final TransactionManager tm = determineTransactionManager(txAttr, targetClass); if (this.reactiveAdapterRegistry != null && tm instanceof ReactiveTransactionManager rtm) { boolean isSuspendingFunction = KotlinDetector.isSuspendingFunction(method); @@ -395,7 +396,9 @@ protected Object invokeWithinTransaction(Method method, @Nullable Class targe future.get(); } catch (ExecutionException ex) { - if (txAttr.rollbackOn(ex.getCause())) { + Throwable cause = ex.getCause(); + Assert.state(cause != null, "Cause must not be null"); + if (txAttr.rollbackOn(cause)) { status.setRollbackOnly(); } } @@ -486,9 +489,19 @@ protected void clearTransactionManagerCache() { /** * Determine the specific transaction manager to use for the given transaction. + * @param txAttr the current transaction attribute + * @param targetClass the target class that the attribute has been declared on + * @since 6.2 */ @Nullable - protected TransactionManager determineTransactionManager(@Nullable TransactionAttribute txAttr) { + protected TransactionManager determineTransactionManager( + @Nullable TransactionAttribute txAttr, @Nullable Class targetClass) { + + TransactionManager tm = determineTransactionManager(txAttr); + if (tm != null) { + return tm; + } + // Do not attempt to lookup tx manager if no tx attributes are set if (txAttr == null || this.beanFactory == null) { return getTransactionManager(); @@ -498,7 +511,20 @@ protected TransactionManager determineTransactionManager(@Nullable TransactionAt if (StringUtils.hasText(qualifier)) { return determineQualifiedTransactionManager(this.beanFactory, qualifier); } - else if (StringUtils.hasText(this.transactionManagerBeanName)) { + else if (targetClass != null) { + // Consider type-level qualifier annotations for transaction manager selection + String typeQualifier = BeanFactoryAnnotationUtils.getQualifierValue(targetClass); + if (StringUtils.hasText(typeQualifier)) { + try { + return determineQualifiedTransactionManager(this.beanFactory, typeQualifier); + } + catch (NoSuchBeanDefinitionException ex) { + // Consider type qualifier as optional, proceed with regular resolution below. + } + } + } + + if (StringUtils.hasText(this.transactionManagerBeanName)) { return determineQualifiedTransactionManager(this.beanFactory, this.transactionManagerBeanName); } else { @@ -515,6 +541,16 @@ else if (StringUtils.hasText(this.transactionManagerBeanName)) { } } + /** + * Determine the specific transaction manager to use for the given transaction. + * @deprecated as of 6.2, in favor of {@link #determineTransactionManager(TransactionAttribute, Class)} + */ + @Deprecated + @Nullable + protected TransactionManager determineTransactionManager(@Nullable TransactionAttribute txAttr) { + return null; + } + private TransactionManager determineQualifiedTransactionManager(BeanFactory beanFactory, String qualifier) { TransactionManager txManager = this.transactionManagerCache.get(qualifier); if (txManager == null) { @@ -525,7 +561,6 @@ private TransactionManager determineQualifiedTransactionManager(BeanFactory bean return txManager; } - @Nullable private PlatformTransactionManager asPlatformTransactionManager(@Nullable Object transactionManager) { if (transactionManager == null) { @@ -839,7 +874,9 @@ public ThrowableHolderException(Throwable throwable) { @Override public String toString() { - return getCause().toString(); + Throwable cause = getCause(); + Assert.state(cause != null, "Cause must not be null"); + return cause.toString(); } } diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAttributeSource.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAttributeSource.java index 74eb470229f9..8514092f4f0a 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAttributeSource.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAttributeSource.java @@ -48,11 +48,24 @@ public interface TransactionAttributeSource { * attributes at class or method level; {@code true} otherwise. The default * implementation returns {@code true}, leading to regular introspection. * @since 5.2 + * @see #hasTransactionAttribute */ default boolean isCandidateClass(Class targetClass) { return true; } + /** + * Determine whether there is a transaction attribute for the given method. + * @param method the method to introspect + * @param targetClass the target class (can be {@code null}, + * in which case the declaring class of the method must be used) + * @since 6.2 + * @see #getTransactionAttribute + */ + default boolean hasTransactionAttribute(Method method, @Nullable Class targetClass) { + return (getTransactionAttribute(method, targetClass) != null); + } + /** * Return the transaction attribute for the given method, * or {@code null} if the method is non-transactional. diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAttributeSourceEditor.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAttributeSourceEditor.java index f1d8c1d33324..6186e99c37ab 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAttributeSourceEditor.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAttributeSourceEditor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ * {@link TransactionAttributeEditor} in this package. * *

      Strings are in property syntax, with the form:
      - * {@code FQCN.methodName=} + * {@code .=} * *

      For example:
      * {@code com.mycompany.mycode.MyClass.myMethod=PROPAGATION_MANDATORY,ISOLATION_DEFAULT} @@ -39,7 +39,8 @@ * *

      Note: Will register all overloaded methods for a given name. * Does not support explicit registration of certain overloaded methods. - * Supports "xxx*" mappings, e.g. "notify*" for "notify" and "notifyAll". + * Supports "xxx*" mappings — for example, "notify*" will match against + * "notify" and "notifyAll". * * @author Rod Johnson * @author Juergen Hoeller diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAttributeSourcePointcut.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAttributeSourcePointcut.java index d388850da43b..319715b4562f 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAttributeSourcePointcut.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAttributeSourcePointcut.java @@ -53,7 +53,7 @@ public void setTransactionAttributeSource(@Nullable TransactionAttributeSource t @Override public boolean matches(Method method, Class targetClass) { return (this.transactionAttributeSource == null || - this.transactionAttributeSource.getTransactionAttribute(method, targetClass) != null); + this.transactionAttributeSource.hasTransactionAttribute(method, targetClass)); } @Override diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionProxyFactoryBean.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionProxyFactoryBean.java index 774107b46ff1..723d1eb877f3 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionProxyFactoryBean.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionProxyFactoryBean.java @@ -133,7 +133,7 @@ public void setTransactionManager(PlatformTransactionManager transactionManager) /** * Set properties with method names as keys and transaction attribute * descriptors (parsed via TransactionAttributeEditor) as values: - * e.g. key = "myMethod", value = "PROPAGATION_REQUIRED,readOnly". + * for example, key = "myMethod", value = "PROPAGATION_REQUIRED,readOnly". *

      Note: Method names are always applied to the target class, * no matter if defined in an interface or the class itself. *

      Internally, a NameMatchTransactionAttributeSource will be diff --git a/spring-tx/src/main/java/org/springframework/transaction/jta/JtaTransactionManager.java b/spring-tx/src/main/java/org/springframework/transaction/jta/JtaTransactionManager.java index b24144f6b62c..c937dbcd3bce 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/jta/JtaTransactionManager.java +++ b/spring-tx/src/main/java/org/springframework/transaction/jta/JtaTransactionManager.java @@ -62,7 +62,7 @@ * *

      This transaction manager is appropriate for handling distributed transactions, * i.e. transactions that span multiple resources, and for controlling transactions on - * application server resources (e.g. JDBC DataSources available in JNDI) in general. + * application server resources (for example, JDBC DataSources available in JNDI) in general. * For a single JDBC DataSource, DataSourceTransactionManager is perfectly sufficient, * and for accessing a single resource with Hibernate (including transactional cache), * HibernateTransactionManager is appropriate, for example. @@ -92,7 +92,7 @@ * API in addition to the standard JTA UserTransaction handle. As of Spring 2.5, this * JtaTransactionManager autodetects the TransactionSynchronizationRegistry and uses * it for registering Spring-managed synchronizations when participating in an existing - * JTA transaction (e.g. controlled by EJB CMT). If no TransactionSynchronizationRegistry + * JTA transaction (for example, controlled by EJB CMT). If no TransactionSynchronizationRegistry * is available, then such synchronizations will be registered via the (non-EE) JTA * TransactionManager handle. * @@ -420,7 +420,7 @@ public void setAutodetectTransactionSynchronizationRegistry(boolean autodetectTr *

      Default is "false", throwing an exception if a non-default isolation level * is specified for a transaction. Turn this flag on if affected resource adapters * check the thread-bound transaction context and apply the specified isolation - * levels individually (e.g. through an IsolationLevelDataSourceAdapter). + * levels individually (for example, through an IsolationLevelDataSourceAdapter). * @see org.springframework.jdbc.datasource.IsolationLevelDataSourceAdapter * @see org.springframework.jdbc.datasource.lookup.IsolationLevelDataSourceRouter */ diff --git a/spring-tx/src/main/java/org/springframework/transaction/jta/SpringJtaSynchronizationAdapter.java b/spring-tx/src/main/java/org/springframework/transaction/jta/SpringJtaSynchronizationAdapter.java index 4beebb30b339..622339fcdfdc 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/jta/SpringJtaSynchronizationAdapter.java +++ b/spring-tx/src/main/java/org/springframework/transaction/jta/SpringJtaSynchronizationAdapter.java @@ -122,7 +122,7 @@ public void beforeCompletion() { finally { // Process Spring's beforeCompletion early, in order to avoid issues // with strict JTA implementations that issue warnings when doing JDBC - // operations after transaction completion (e.g. Connection.getWarnings). + // operations after transaction completion (for example, Connection.getWarnings). this.beforeCompletionCalled = true; this.springSynchronization.beforeCompletion(); } diff --git a/spring-tx/src/main/java/org/springframework/transaction/reactive/AbstractReactiveTransactionManager.java b/spring-tx/src/main/java/org/springframework/transaction/reactive/AbstractReactiveTransactionManager.java index 6efe444555c2..8a3a5056b6a7 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/reactive/AbstractReactiveTransactionManager.java +++ b/spring-tx/src/main/java/org/springframework/transaction/reactive/AbstractReactiveTransactionManager.java @@ -59,7 +59,7 @@ * * *

      Subclasses have to implement specific template methods for specific - * states of a transaction, e.g.: begin, suspend, resume, commit, rollback. + * states of a transaction, for example: begin, suspend, resume, commit, rollback. * The most important of them are abstract and must be provided by a concrete * implementation; for the rest, defaults are provided, so overriding is optional. * @@ -763,7 +763,7 @@ private Mono cleanupAfterCompletion(TransactionSynchronizationManager sync *

      The returned object will usually be specific to the concrete transaction * manager implementation, carrying corresponding transaction state in a * modifiable fashion. This object will be passed into the other template - * methods (e.g. doBegin and doCommit), either directly or as part of a + * methods (for example, doBegin and doCommit), either directly or as part of a * DefaultReactiveTransactionStatus instance. *

      The returned object should contain information about any existing * transaction, that is, a transaction that has already started before the @@ -816,7 +816,7 @@ protected boolean isExistingTransaction(Object transaction) { * @param definition a TransactionDefinition instance, describing propagation * behavior, isolation level, read-only flag, timeout, and transaction name * @throws org.springframework.transaction.NestedTransactionNotSupportedException - * if the underlying transaction does not support nesting (e.g. through savepoints) + * if the underlying transaction does not support nesting (for example, through savepoints) */ protected abstract Mono doBegin(TransactionSynchronizationManager synchronizationManager, Object transaction, TransactionDefinition definition); diff --git a/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionCallback.java b/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionCallback.java index 63546c24c435..a5974d5f2625 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionCallback.java +++ b/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionCallback.java @@ -26,7 +26,7 @@ * *

      Typically used to assemble various calls to transaction-unaware data access * services into a higher-level service method with transaction demarcation. As an - * alternative, consider the use of declarative transaction demarcation (e.g. through + * alternative, consider the use of declarative transaction demarcation (for example, through * Spring's {@link org.springframework.transaction.annotation.Transactional} annotation). * * @author Mark Paluch @@ -42,7 +42,7 @@ public interface TransactionCallback { * Gets called by {@link TransactionalOperator} within a transactional context. * Does not need to care about transactions itself, although it can retrieve and * influence the status of the current transaction via the given status object, - * e.g. setting rollback-only. + * for example, setting rollback-only. * @param status associated transaction status * @return a result publisher * @see TransactionalOperator#transactional diff --git a/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionSynchronization.java b/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionSynchronization.java index ac8309f4c8fe..dc1774d916a4 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionSynchronization.java +++ b/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionSynchronization.java @@ -101,7 +101,7 @@ default Mono beforeCompletion() { /** * Invoked after transaction commit. Can perform further operations right * after the main transaction has successfully committed. - *

      Can e.g. commit further operations that are supposed to follow on a successful + *

      Can, for example, commit further operations that are supposed to follow on a successful * commit of the main transaction, like confirmation messages or emails. *

      NOTE: The transaction will have been committed already, but the * transactional resources might still be active and accessible. As a consequence, diff --git a/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionSynchronizationManager.java b/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionSynchronizationManager.java index b5a1b11ebad5..1c2fa3a5bf81 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionSynchronizationManager.java +++ b/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionSynchronizationManager.java @@ -39,7 +39,7 @@ * to be removed before a new one can be set for the same key. * Supports a list of transaction synchronizations if synchronization is active. * - *

      Resource management code should check for context-bound resources, e.g. + *

      Resource management code should check for context-bound resources, for example, * database connections, via {@code getResource}. Such code is normally not * supposed to bind resources to units of work, as this is the responsibility * of transaction managers. A further option is to lazily bind on first use if @@ -58,7 +58,7 @@ * doesn't support transaction synchronization. * *

      Synchronization is for example used to always return the same resources within - * a transaction, e.g. a database connection for any given connection factory. + * a transaction, for example, a database connection for any given connection factory. * * @author Mark Paluch * @author Juergen Hoeller diff --git a/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionalEventPublisher.java b/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionalEventPublisher.java index 443d4607154d..dfb8fed6d186 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionalEventPublisher.java +++ b/spring-tx/src/main/java/org/springframework/transaction/reactive/TransactionalEventPublisher.java @@ -65,7 +65,7 @@ public TransactionalEventPublisher(ApplicationEventPublisher eventPublisher) { * Publish an event created through the given function which maps the transaction * source object (the {@link TransactionContext}) to the event instance. * @param eventCreationFunction a function mapping the source object to the event instance, - * e.g. {@code source -> new PayloadApplicationEvent<>(source, "myPayload")} + * for example, {@code source -> new PayloadApplicationEvent<>(source, "myPayload")} * @return the Reactor {@link Mono} for the transactional event publication */ public Mono publishEvent(Function eventCreationFunction) { diff --git a/spring-tx/src/main/java/org/springframework/transaction/support/AbstractPlatformTransactionManager.java b/spring-tx/src/main/java/org/springframework/transaction/support/AbstractPlatformTransactionManager.java index 8da5bab11855..73b56a20152b 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/support/AbstractPlatformTransactionManager.java +++ b/spring-tx/src/main/java/org/springframework/transaction/support/AbstractPlatformTransactionManager.java @@ -59,7 +59,7 @@ * * *

      Subclasses have to implement specific template methods for specific - * states of a transaction, e.g.: begin, suspend, resume, commit, rollback. + * states of a transaction, for example: begin, suspend, resume, commit, rollback. * The most important of them are abstract and must be provided by a concrete * implementation; for the rest, defaults are provided, so overriding is optional. * @@ -67,7 +67,7 @@ * that get invoked at transaction completion time. This is mainly used internally * by the data access support classes for JDBC, Hibernate, JPA, etc when running * within a JTA transaction: They register resources that are opened within the - * transaction for closing at transaction completion time, allowing e.g. for reuse + * transaction for closing at transaction completion time, allowing, for example, for reuse * of the same Hibernate Session within the transaction. The same mechanism can * also be leveraged for custom synchronization needs in an application. * @@ -188,7 +188,7 @@ public final int getTransactionSynchronization() { * Specify the default timeout that this transaction manager should apply * if there is no timeout specified at the transaction level, in seconds. *

      Default is the underlying transaction infrastructure's default timeout, - * e.g. typically 30 seconds in case of a JTA provider, indicated by the + * for example, typically 30 seconds in case of a JTA provider, indicated by the * {@code TransactionDefinition.TIMEOUT_DEFAULT} value. * @see org.springframework.transaction.TransactionDefinition#TIMEOUT_DEFAULT */ @@ -228,7 +228,7 @@ public final boolean isNestedTransactionAllowed() { /** * Set whether existing transactions should be validated before participating * in them. - *

      When participating in an existing transaction (e.g. with + *

      When participating in an existing transaction (for example, with * PROPAGATION_REQUIRED or PROPAGATION_SUPPORTS encountering an existing * transaction), this outer transaction's characteristics will apply even * to the inner transaction scope. Validation will detect incompatible @@ -255,7 +255,7 @@ public final boolean isValidateExistingTransaction() { /** * Set whether to globally mark an existing transaction as rollback-only * after a participating transaction failed. - *

      Default is "true": If a participating transaction (e.g. with + *

      Default is "true": If a participating transaction (for example, with * PROPAGATION_REQUIRED or PROPAGATION_SUPPORTS encountering an existing * transaction) fails, the transaction will be globally marked as rollback-only. * The only possible outcome of such a transaction is a rollback: The @@ -1080,7 +1080,7 @@ private void cleanupAfterCompletion(DefaultTransactionStatus status) { *

      The returned object will usually be specific to the concrete transaction * manager implementation, carrying corresponding transaction state in a * modifiable fashion. This object will be passed into the other template - * methods (e.g. doBegin and doCommit), either directly or as part of a + * methods (for example, doBegin and doCommit), either directly or as part of a * DefaultTransactionStatus instance. *

      The returned object should contain information about any existing * transaction, that is, a transaction that has already started before the diff --git a/spring-tx/src/main/java/org/springframework/transaction/support/AbstractTransactionStatus.java b/spring-tx/src/main/java/org/springframework/transaction/support/AbstractTransactionStatus.java index e0ce47b45bfe..fa7fcff4ae8f 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/support/AbstractTransactionStatus.java +++ b/spring-tx/src/main/java/org/springframework/transaction/support/AbstractTransactionStatus.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -138,14 +138,19 @@ protected Object getSavepoint() { * Create a savepoint and hold it for the transaction. * @throws org.springframework.transaction.NestedTransactionNotSupportedException * if the underlying transaction does not support savepoints + * @see SavepointManager#createSavepoint */ public void createAndHoldSavepoint() throws TransactionException { - setSavepoint(getSavepointManager().createSavepoint()); + Object savepoint = getSavepointManager().createSavepoint(); + TransactionSynchronizationUtils.triggerSavepoint(savepoint); + setSavepoint(savepoint); } /** * Roll back to the savepoint that is held for the transaction * and release the savepoint right afterwards. + * @see SavepointManager#rollbackToSavepoint + * @see SavepointManager#releaseSavepoint */ public void rollbackToHeldSavepoint() throws TransactionException { Object savepoint = getSavepoint(); @@ -153,6 +158,7 @@ public void rollbackToHeldSavepoint() throws TransactionException { throw new TransactionUsageException( "Cannot roll back to savepoint - no savepoint associated with current transaction"); } + TransactionSynchronizationUtils.triggerSavepointRollback(savepoint); getSavepointManager().rollbackToSavepoint(savepoint); getSavepointManager().releaseSavepoint(savepoint); setSavepoint(null); @@ -160,6 +166,7 @@ public void rollbackToHeldSavepoint() throws TransactionException { /** * Release the savepoint that is held for the transaction. + * @see SavepointManager#releaseSavepoint */ public void releaseHeldSavepoint() throws TransactionException { Object savepoint = getSavepoint(); @@ -184,7 +191,9 @@ public void releaseHeldSavepoint() throws TransactionException { */ @Override public Object createSavepoint() throws TransactionException { - return getSavepointManager().createSavepoint(); + Object savepoint = getSavepointManager().createSavepoint(); + TransactionSynchronizationUtils.triggerSavepoint(savepoint); + return savepoint; } /** @@ -195,6 +204,7 @@ public Object createSavepoint() throws TransactionException { */ @Override public void rollbackToSavepoint(Object savepoint) throws TransactionException { + TransactionSynchronizationUtils.triggerSavepointRollback(savepoint); getSavepointManager().rollbackToSavepoint(savepoint); } diff --git a/spring-tx/src/main/java/org/springframework/transaction/support/ResourceHolderSupport.java b/spring-tx/src/main/java/org/springframework/transaction/support/ResourceHolderSupport.java index c58256223c09..c0b2bdaf636f 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/support/ResourceHolderSupport.java +++ b/spring-tx/src/main/java/org/springframework/transaction/support/ResourceHolderSupport.java @@ -71,7 +71,7 @@ public void setRollbackOnly() { /** * Reset the rollback-only status for this resource transaction. *

      Only really intended to be called after custom rollback steps which - * keep the original resource in action, e.g. in case of a savepoint. + * keep the original resource in action, for example, in case of a savepoint. * @since 5.0 * @see org.springframework.transaction.SavepointManager#rollbackToSavepoint */ @@ -120,7 +120,7 @@ public Date getDeadline() { /** * Return the time to live for this object in seconds. - * Rounds up eagerly, e.g. 9.00001 still to 10. + * Rounds up eagerly, for example, 9.00001 still to 10. * @return number of seconds until expiration * @throws TransactionTimedOutException if the deadline has already been reached */ diff --git a/spring-tx/src/main/java/org/springframework/transaction/support/ResourceTransactionManager.java b/spring-tx/src/main/java/org/springframework/transaction/support/ResourceTransactionManager.java index f6ee501bba46..09d8472715ec 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/support/ResourceTransactionManager.java +++ b/spring-tx/src/main/java/org/springframework/transaction/support/ResourceTransactionManager.java @@ -37,7 +37,7 @@ public interface ResourceTransactionManager extends PlatformTransactionManager { /** * Return the resource factory that this transaction manager operates on, - * e.g. a JDBC DataSource or a JMS ConnectionFactory. + * for example, a JDBC DataSource or a JMS ConnectionFactory. *

      This target resource factory is usually used as resource key for * {@link TransactionSynchronizationManager}'s resource bindings per thread. * @return the target resource factory (never {@code null}) diff --git a/spring-tx/src/main/java/org/springframework/transaction/support/TransactionCallback.java b/spring-tx/src/main/java/org/springframework/transaction/support/TransactionCallback.java index 926cdaf9289c..70b76cf96fc2 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/support/TransactionCallback.java +++ b/spring-tx/src/main/java/org/springframework/transaction/support/TransactionCallback.java @@ -25,7 +25,7 @@ * *

      Typically used to assemble various calls to transaction-unaware data access * services into a higher-level service method with transaction demarcation. As an - * alternative, consider the use of declarative transaction demarcation (e.g. through + * alternative, consider the use of declarative transaction demarcation (for example, through * Spring's {@link org.springframework.transaction.annotation.Transactional} annotation). * * @author Juergen Hoeller @@ -41,7 +41,7 @@ public interface TransactionCallback { * Gets called by {@link TransactionTemplate#execute} within a transactional context. * Does not need to care about transactions itself, although it can retrieve and * influence the status of the current transaction via the given status object, - * e.g. setting rollback-only. + * for example, setting rollback-only. *

      Allows for returning a result object created within the transaction, i.e. a * domain object or a collection of domain objects. A RuntimeException thrown by the * callback is treated as application exception that enforces a rollback. Any such diff --git a/spring-tx/src/main/java/org/springframework/transaction/support/TransactionCallbackWithoutResult.java b/spring-tx/src/main/java/org/springframework/transaction/support/TransactionCallbackWithoutResult.java index 84eb4d890088..4552e5796142 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/support/TransactionCallbackWithoutResult.java +++ b/spring-tx/src/main/java/org/springframework/transaction/support/TransactionCallbackWithoutResult.java @@ -41,7 +41,7 @@ public final Object doInTransaction(TransactionStatus status) { * Gets called by {@code TransactionTemplate.execute} within a transactional * context. Does not need to care about transactions itself, although it can retrieve * and influence the status of the current transaction via the given status object, - * e.g. setting rollback-only. + * for example, setting rollback-only. *

      A RuntimeException thrown by the callback is treated as application * exception that enforces a rollback. An exception gets propagated to the * caller of the template. diff --git a/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronization.java b/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronization.java index bdf94d9567fb..5d47f89db0bb 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronization.java +++ b/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronization.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +88,37 @@ default void resume() { default void flush() { } + /** + * Invoked on creation of a new savepoint, either when a nested transaction + * is started against an existing transaction or on a programmatic savepoint + * via {@link org.springframework.transaction.TransactionStatus}. + *

      This synchronization callback is invoked right after the creation + * of the resource savepoint, with the given savepoint object already active. + * @param savepoint the associated savepoint object (primarily as a key for + * identifying the savepoint but also castable to the resource savepoint type) + * @since 6.2 + * @see org.springframework.transaction.SavepointManager#createSavepoint + * @see org.springframework.transaction.TransactionDefinition#PROPAGATION_NESTED + */ + default void savepoint(Object savepoint) { + } + + /** + * Invoked in case of a rollback to the previously created savepoint. + *

      This synchronization callback is invoked right before the rollback + * of the resource savepoint, with the given savepoint object still active. + * @param savepoint the associated savepoint object (primarily as a key for + * identifying the savepoint but also castable to the resource savepoint type) + * @since 6.2 + * @see #savepoint + * @see org.springframework.transaction.SavepointManager#rollbackToSavepoint + */ + default void savepointRollback(Object savepoint) { + } + /** * Invoked before transaction commit (before "beforeCompletion"). - * Can e.g. flush transactional O/R Mapping sessions to the database. + * Can, for example, flush transactional O/R Mapping sessions to the database. *

      This callback does not mean that the transaction will actually be committed. * A rollback decision can still occur after this method has been called. This callback * is rather meant to perform work that's only relevant if a commit still has a chance @@ -122,7 +150,7 @@ default void beforeCompletion() { /** * Invoked after transaction commit. Can perform further operations right * after the main transaction has successfully committed. - *

      Can e.g. commit further operations that are supposed to follow on a successful + *

      Can, for example, commit further operations that are supposed to follow on a successful * commit of the main transaction, like confirmation messages or emails. *

      NOTE: The transaction will have been committed already, but the * transactional resources might still be active and accessible. As a consequence, diff --git a/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java b/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java index f7fb0d40c0d0..0b310c99e136 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java +++ b/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java @@ -37,7 +37,7 @@ * to be removed before a new one can be set for the same key. * Supports a list of transaction synchronizations if synchronization is active. * - *

      Resource management code should check for thread-bound resources, e.g. JDBC + *

      Resource management code should check for thread-bound resources, for example, JDBC * Connections or Hibernate Sessions, via {@code getResource}. Such code is * normally not supposed to bind resources to threads, as this is the responsibility * of transaction managers. A further option is to lazily bind on first use if @@ -58,7 +58,7 @@ * doesn't support transaction synchronization. * *

      Synchronization is for example used to always return the same resources - * within a JTA transaction, e.g. a JDBC Connection or a Hibernate Session for + * within a JTA transaction, for example, a JDBC Connection or a Hibernate Session for * any given DataSource or SessionFactory, respectively. * * @author Juergen Hoeller 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 e3effdc99179..8da53ffb9c1e 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 @@ -81,8 +81,38 @@ public static Object unwrapResourceIfNecessary(Object resource) { * @see TransactionSynchronization#flush() */ public static void triggerFlush() { - for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) { - synchronization.flush(); + if (TransactionSynchronizationManager.isSynchronizationActive()) { + for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) { + synchronization.flush(); + } + } + } + + /** + * Trigger {@code flush} callbacks on all currently registered synchronizations. + * @throws RuntimeException if thrown by a {@code savepoint} callback + * @since 6.2 + * @see TransactionSynchronization#savepoint + */ + static void triggerSavepoint(Object savepoint) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) { + synchronization.savepoint(savepoint); + } + } + } + + /** + * Trigger {@code flush} callbacks on all currently registered synchronizations. + * @throws RuntimeException if thrown by a {@code savepointRollback} callback + * @since 6.2 + * @see TransactionSynchronization#savepointRollback + */ + static void triggerSavepointRollback(Object savepoint) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) { + synchronization.savepointRollback(savepoint); + } } } diff --git a/spring-tx/src/test/java/org/springframework/dao/support/DataAccessUtilsTests.java b/spring-tx/src/test/java/org/springframework/dao/support/DataAccessUtilsTests.java index f80e92df139c..28dd0d2f0e41 100644 --- a/spring-tx/src/test/java/org/springframework/dao/support/DataAccessUtilsTests.java +++ b/spring-tx/src/test/java/org/springframework/dao/support/DataAccessUtilsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,21 +52,25 @@ void withEmptyCollection() { assertThat(DataAccessUtils.optionalResult(col.stream())).isEmpty(); assertThat(DataAccessUtils.optionalResult(col.iterator())).isEmpty(); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.requiredUniqueResult(col)) - .satisfies(sizeRequirements(1, 0)); + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.requiredSingleResult(col)) + .satisfies(sizeRequirements(1, 0)); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.objectResult(col, String.class)) - .satisfies(sizeRequirements(1, 0)); + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.requiredUniqueResult(col)) + .satisfies(sizeRequirements(1, 0)); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.intResult(col)) - .satisfies(sizeRequirements(1, 0)); + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.objectResult(col, String.class)) + .satisfies(sizeRequirements(1, 0)); + + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.intResult(col)) + .satisfies(sizeRequirements(1, 0)); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.longResult(col)) - .satisfies(sizeRequirements(1, 0)); + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.longResult(col)) + .satisfies(sizeRequirements(1, 0)); } @Test @@ -75,49 +79,83 @@ void withTooLargeCollection() { col.add("test1"); col.add("test2"); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.uniqueResult(col)) - .satisfies(sizeRequirements(1, 2)); + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.uniqueResult(col)) + .satisfies(sizeRequirements(1, 2)); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.requiredUniqueResult(col)) - .satisfies(sizeRequirements(1, 2)); + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.requiredUniqueResult(col)) + .satisfies(sizeRequirements(1, 2)); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.objectResult(col, String.class)) - .satisfies(sizeRequirements(1, 2)); + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.objectResult(col, String.class)) + .satisfies(sizeRequirements(1, 2)); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.intResult(col)) - .satisfies(sizeRequirements(1, 2)); + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.intResult(col)) + .satisfies(sizeRequirements(1, 2)); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.longResult(col)) - .satisfies(sizeRequirements(1, 2)); + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.longResult(col)) + .satisfies(sizeRequirements(1, 2)); + + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.requiredSingleResult(col)) + .satisfies(sizeRequirements(1, 2)); + + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.singleResult(col)) + .satisfies(sizeRequirements(1, 2)); + + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.singleResult(col.stream())) + .satisfies(sizeRequirements(1)); + + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.singleResult(col.iterator())) + .satisfies(sizeRequirements(1)); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.singleResult(col)) - .satisfies(sizeRequirements(1, 2)); + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.optionalResult(col)) + .satisfies(sizeRequirements(1, 2)); + + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.optionalResult(col.stream())) + .satisfies(sizeRequirements(1)); + + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.optionalResult(col.iterator())) + .satisfies(sizeRequirements(1)); + } + + @Test + void withNullValueInCollection() { + Collection col = new HashSet<>(); + col.add(null); + + assertThat(DataAccessUtils.uniqueResult(col)).isNull(); + + assertThat(DataAccessUtils.singleResult(col)).isNull(); + assertThat(DataAccessUtils.singleResult(col.stream())).isNull(); + assertThat(DataAccessUtils.singleResult(col.iterator())).isNull(); + assertThat(DataAccessUtils.optionalResult(col)).isEmpty(); + assertThat(DataAccessUtils.optionalResult(col.stream())).isEmpty(); + assertThat(DataAccessUtils.optionalResult(col.iterator())).isEmpty(); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.singleResult(col.stream())) - .satisfies(sizeRequirements(1)); + assertThatExceptionOfType(TypeMismatchDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.requiredSingleResult(col)); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.singleResult(col.iterator())) - .satisfies(sizeRequirements(1)); + assertThatExceptionOfType(TypeMismatchDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.requiredUniqueResult(col)); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.optionalResult(col)) - .satisfies(sizeRequirements(1, 2)); + assertThatExceptionOfType(TypeMismatchDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.objectResult(col, String.class)); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.optionalResult(col.stream())) - .satisfies(sizeRequirements(1)); + assertThatExceptionOfType(TypeMismatchDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.intResult(col)); - assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class).isThrownBy(() -> - DataAccessUtils.optionalResult(col.iterator())) - .satisfies(sizeRequirements(1)); + assertThatExceptionOfType(TypeMismatchDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.longResult(col)); } @Test @@ -131,6 +169,7 @@ void withInteger() { assertThat(DataAccessUtils.objectResult(col, String.class)).isEqualTo("5"); assertThat(DataAccessUtils.intResult(col)).isEqualTo(5); assertThat(DataAccessUtils.longResult(col)).isEqualTo(5); + assertThat(DataAccessUtils.requiredSingleResult(col)).isEqualTo(Integer.valueOf(5)); assertThat(DataAccessUtils.singleResult(col)).isEqualTo(5); assertThat(DataAccessUtils.singleResult(col.stream())).isEqualTo(5); assertThat(DataAccessUtils.singleResult(col.iterator())).isEqualTo(5); @@ -159,8 +198,8 @@ void withEquivalentIntegerInstanceTwice() { Collection col = Arrays.asList(555, 555); assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) - .isThrownBy(() -> DataAccessUtils.uniqueResult(col)) - .satisfies(sizeRequirements(1, 2)); + .isThrownBy(() -> DataAccessUtils.uniqueResult(col)) + .satisfies(sizeRequirements(1, 2)); } @Test @@ -174,6 +213,7 @@ void withLong() { assertThat(DataAccessUtils.objectResult(col, String.class)).isEqualTo("5"); assertThat(DataAccessUtils.intResult(col)).isEqualTo(5); assertThat(DataAccessUtils.longResult(col)).isEqualTo(5); + assertThat(DataAccessUtils.requiredSingleResult(col)).isEqualTo(Long.valueOf(5L)); assertThat(DataAccessUtils.singleResult(col)).isEqualTo(Long.valueOf(5L)); assertThat(DataAccessUtils.singleResult(col.stream())).isEqualTo(Long.valueOf(5L)); assertThat(DataAccessUtils.singleResult(col.iterator())).isEqualTo(Long.valueOf(5L)); @@ -190,6 +230,7 @@ void withString() { assertThat(DataAccessUtils.uniqueResult(col)).isEqualTo("test1"); assertThat(DataAccessUtils.requiredUniqueResult(col)).isEqualTo("test1"); assertThat(DataAccessUtils.objectResult(col, String.class)).isEqualTo("test1"); + assertThat(DataAccessUtils.requiredSingleResult(col)).isEqualTo("test1"); assertThat(DataAccessUtils.singleResult(col)).isEqualTo("test1"); assertThat(DataAccessUtils.singleResult(col.stream())).isEqualTo("test1"); assertThat(DataAccessUtils.singleResult(col.iterator())).isEqualTo("test1"); @@ -197,11 +238,11 @@ void withString() { assertThat(DataAccessUtils.optionalResult(col.stream())).isEqualTo(Optional.of("test1")); assertThat(DataAccessUtils.optionalResult(col.iterator())).isEqualTo(Optional.of("test1")); - assertThatExceptionOfType(TypeMismatchDataAccessException.class).isThrownBy(() -> - DataAccessUtils.intResult(col)); + assertThatExceptionOfType(TypeMismatchDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.intResult(col)); - assertThatExceptionOfType(TypeMismatchDataAccessException.class).isThrownBy(() -> - DataAccessUtils.longResult(col)); + assertThatExceptionOfType(TypeMismatchDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.longResult(col)); } @Test @@ -214,6 +255,7 @@ void withDate() { assertThat(DataAccessUtils.requiredUniqueResult(col)).isEqualTo(date); assertThat(DataAccessUtils.objectResult(col, Date.class)).isEqualTo(date); assertThat(DataAccessUtils.objectResult(col, String.class)).isEqualTo(date.toString()); + assertThat(DataAccessUtils.requiredSingleResult(col)).isEqualTo(date); assertThat(DataAccessUtils.singleResult(col)).isEqualTo(date); assertThat(DataAccessUtils.singleResult(col.stream())).isEqualTo(date); assertThat(DataAccessUtils.singleResult(col.iterator())).isEqualTo(date); @@ -221,11 +263,11 @@ void withDate() { assertThat(DataAccessUtils.optionalResult(col.stream())).isEqualTo(Optional.of(date)); assertThat(DataAccessUtils.optionalResult(col.iterator())).isEqualTo(Optional.of(date)); - assertThatExceptionOfType(TypeMismatchDataAccessException.class).isThrownBy(() -> - DataAccessUtils.intResult(col)); + assertThatExceptionOfType(TypeMismatchDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.intResult(col)); - assertThatExceptionOfType(TypeMismatchDataAccessException.class).isThrownBy(() -> - DataAccessUtils.longResult(col)); + assertThatExceptionOfType(TypeMismatchDataAccessException.class) + .isThrownBy(() -> DataAccessUtils.longResult(col)); } @Test diff --git a/spring-tx/src/test/java/org/springframework/transaction/annotation/EnableTransactionManagementTests.java b/spring-tx/src/test/java/org/springframework/transaction/annotation/EnableTransactionManagementTests.java index 7b97794a869c..b3849246c070 100644 --- a/spring-tx/src/test/java/org/springframework/transaction/annotation/EnableTransactionManagementTests.java +++ b/spring-tx/src/test/java/org/springframework/transaction/annotation/EnableTransactionManagementTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,6 +25,7 @@ import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.NoUniqueBeanDefinitionException; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.AdviceMode; import org.springframework.context.annotation.AnnotationConfigApplicationContext; @@ -48,6 +49,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatException; import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; +import static org.springframework.transaction.annotation.RollbackOn.ALL_EXCEPTIONS; /** * Tests demonstrating use of @EnableTransactionManagement @Configuration classes. @@ -56,6 +58,7 @@ * @author Juergen Hoeller * @author Stephane Nicoll * @author Sam Brannen + * @author Yanming Zhou * @since 3.1 */ class EnableTransactionManagementTests { @@ -241,8 +244,8 @@ void transactionalEventListenerRegisteredProperly() { } @Test - void spr11915TransactionManagerAsManualSingleton() { - AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(Spr11915Config.class); + void transactionManagerAsManualSingleton() { + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(ManualSingletonConfig.class); TransactionalTestBean bean = ctx.getBean(TransactionalTestBean.class); CallCountingTransactionManager txManager = ctx.getBean("qualifiedTransactionManager", CallCountingTransactionManager.class); @@ -261,6 +264,54 @@ void spr11915TransactionManagerAsManualSingleton() { ctx.close(); } + @Test + void transactionManagerViaQualifierAnnotation() { + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(QualifiedTransactionConfig.class); + + TransactionalTestBean bean = ctx.getBean("testBean", TransactionalTestBean.class); + TransactionalTestBeanWithNonExistentQualifier beanWithNonExistentQualifier = ctx.getBean( + "testBeanWithNonExistentQualifier", TransactionalTestBeanWithNonExistentQualifier.class); + TransactionalTestBeanWithInvalidQualifier beanWithInvalidQualifier = ctx.getBean( + "testBeanWithInvalidQualifier", TransactionalTestBeanWithInvalidQualifier.class); + + CallCountingTransactionManager qualified = ctx.getBean("qualifiedTransactionManager", + CallCountingTransactionManager.class); + CallCountingTransactionManager primary = ctx.getBean("primaryTransactionManager", + CallCountingTransactionManager.class); + + bean.saveQualifiedFoo(); + assertThat(qualified.begun).isEqualTo(1); + assertThat(qualified.commits).isEqualTo(1); + assertThat(qualified.rollbacks).isEqualTo(0); + + bean.saveQualifiedFooWithAttributeAlias(); + assertThat(qualified.begun).isEqualTo(2); + assertThat(qualified.commits).isEqualTo(2); + assertThat(qualified.rollbacks).isEqualTo(0); + + bean.findAllFoos(); + assertThat(qualified.begun).isEqualTo(3); + assertThat(qualified.commits).isEqualTo(3); + assertThat(qualified.rollbacks).isEqualTo(0); + + beanWithNonExistentQualifier.findAllFoos(); + assertThat(primary.begun).isEqualTo(1); + assertThat(primary.commits).isEqualTo(1); + assertThat(primary.rollbacks).isEqualTo(0); + + beanWithInvalidQualifier.findAllFoos(); + assertThat(primary.begun).isEqualTo(2); + assertThat(primary.commits).isEqualTo(2); + assertThat(primary.rollbacks).isEqualTo(0); + + // no further access to qualified transaction manager + assertThat(qualified.begun).isEqualTo(3); + assertThat(qualified.commits).isEqualTo(3); + assertThat(qualified.rollbacks).isEqualTo(0); + + ctx.close(); + } + @Test void spr14322AnnotationOnInterfaceWithInterfaceProxy() { AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(Spr14322ConfigA.class); @@ -306,6 +357,36 @@ void gh24502AppliesTransactionFromAnnotatedInterface() { ctx.close(); } + @Test + void gh23473AppliesToRuntimeExceptionOnly() { + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(Gh23473ConfigA.class); + TestServiceWithRollback bean = ctx.getBean("testBean", TestServiceWithRollback.class); + CallCountingTransactionManager txManager = ctx.getBean(CallCountingTransactionManager.class); + + assertThatException().isThrownBy(bean::methodOne); + assertThatException().isThrownBy(bean::methodTwo); + assertThat(txManager.begun).isEqualTo(2); + assertThat(txManager.commits).isEqualTo(2); + assertThat(txManager.rollbacks).isEqualTo(0); + + ctx.close(); + } + + @Test + void gh23473AppliesRollbackOnAnyException() { + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(Gh23473ConfigB.class); + TestServiceWithRollback bean = ctx.getBean("testBean", TestServiceWithRollback.class); + CallCountingTransactionManager txManager = ctx.getBean(CallCountingTransactionManager.class); + + assertThatException().isThrownBy(bean::methodOne); + assertThatException().isThrownBy(bean::methodTwo); + assertThat(txManager.begun).isEqualTo(2); + assertThat(txManager.commits).isEqualTo(0); + assertThat(txManager.rollbacks).isEqualTo(2); + + ctx.close(); + } + @Service public static class TransactionalTestBean { @@ -325,6 +406,22 @@ public void saveQualifiedFooWithAttributeAlias() { } + @Service + @Qualifier("qualified") + public static class TransactionalTestBeanSubclass extends TransactionalTestBean { + } + + @Service + @Qualifier("nonExistentBean") + public static class TransactionalTestBeanWithNonExistentQualifier extends TransactionalTestBean { + } + + @Service + @Qualifier("transactionalTestBeanWithInvalidQualifier") + public static class TransactionalTestBeanWithInvalidQualifier extends TransactionalTestBean { + } + + @Configuration static class PlaceholderConfig { @@ -496,7 +593,7 @@ public PlatformTransactionManager annotationDrivenTransactionManager() { @Configuration @EnableTransactionManagement @Import(PlaceholderConfig.class) - static class Spr11915Config { + static class ManualSingletonConfig { @Autowired public void initializeApp(ConfigurableApplicationContext applicationContext) { @@ -516,6 +613,41 @@ public CallCountingTransactionManager otherTxManager() { } + @Configuration + @EnableTransactionManagement + @Import(PlaceholderConfig.class) + static class QualifiedTransactionConfig { + + @Autowired + public void initializeApp(ConfigurableApplicationContext applicationContext) { + applicationContext.getBeanFactory().registerSingleton( + "qualifiedTransactionManager", new CallCountingTransactionManager()); + applicationContext.getBeanFactory().registerAlias("qualifiedTransactionManager", "qualified"); + } + + @Bean + public TransactionalTestBeanSubclass testBean() { + return new TransactionalTestBeanSubclass(); + } + + @Bean + public TransactionalTestBeanWithNonExistentQualifier testBeanWithNonExistentQualifier() { + return new TransactionalTestBeanWithNonExistentQualifier(); + } + + @Bean + public TransactionalTestBeanWithInvalidQualifier testBeanWithInvalidQualifier() { + return new TransactionalTestBeanWithInvalidQualifier(); + } + + @Bean + @Primary + public CallCountingTransactionManager primaryTransactionManager() { + return new CallCountingTransactionManager(); + } + } + + public interface BaseTransactionalInterface { @Transactional @@ -612,4 +744,50 @@ public PlatformTransactionManager txManager() { } } + + static class TestServiceWithRollback { + + @Transactional + public void methodOne() throws Exception { + throw new Exception(); + } + + @Transactional + public void methodTwo() throws Exception { + throw new Exception(); + } + } + + + @Configuration + @EnableTransactionManagement + static class Gh23473ConfigA { + + @Bean + public TestServiceWithRollback testBean() { + return new TestServiceWithRollback(); + } + + @Bean + public PlatformTransactionManager txManager() { + return new CallCountingTransactionManager(); + } + } + + + @Configuration + @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) + static class Gh23473ConfigB { + + @Bean + public TestServiceWithRollback testBean() { + return new TestServiceWithRollback(); + } + + @Bean + public PlatformTransactionManager txManager() { + return new CallCountingTransactionManager(); + } + } + } 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 686e5f9d44f5..23485164f0c2 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 @@ -157,6 +157,34 @@ void withAsyncTransactionalAnnotation() { assertThatNoException().isThrownBy(() -> factory.createApplicationListener("test", SampleEvents.class, m)); } + @Test + void withTransactionalAnnotationOnEnclosingClass() { + RestrictedTransactionalEventListenerFactory factory = new RestrictedTransactionalEventListenerFactory(); + Method m = ReflectionUtils.findMethod(SampleEvents.SampleEventsWithTransactionalAnnotation.class, "defaultPhase", String.class); + assertThatIllegalStateException().isThrownBy(() -> factory.createApplicationListener("test", SampleEvents.SampleEventsWithTransactionalAnnotation.class, m)); + } + + @Test + void withTransactionalRequiresNewAnnotationAndTransactionalAnnotationOnEnclosingClass() { + RestrictedTransactionalEventListenerFactory factory = new RestrictedTransactionalEventListenerFactory(); + Method m = ReflectionUtils.findMethod(SampleEvents.SampleEventsWithTransactionalAnnotation.class, "withTransactionalRequiresNewAnnotation", String.class); + assertThatNoException().isThrownBy(() -> factory.createApplicationListener("test", SampleEvents.SampleEventsWithTransactionalAnnotation.class, m)); + } + + @Test + void withTransactionalNotSupportedAnnotationAndTransactionalAnnotationOnEnclosingClass() { + RestrictedTransactionalEventListenerFactory factory = new RestrictedTransactionalEventListenerFactory(); + Method m = ReflectionUtils.findMethod(SampleEvents.SampleEventsWithTransactionalAnnotation.class, "withTransactionalNotSupportedAnnotation", String.class); + assertThatNoException().isThrownBy(() -> factory.createApplicationListener("test", SampleEvents.SampleEventsWithTransactionalAnnotation.class, m)); + } + + @Test + void withAsyncTransactionalAnnotationAndTransactionalAnnotationOnEnclosingClass() { + RestrictedTransactionalEventListenerFactory factory = new RestrictedTransactionalEventListenerFactory(); + Method m = ReflectionUtils.findMethod(SampleEvents.SampleEventsWithTransactionalAnnotation.class, "withAsyncTransactionalAnnotation", String.class); + assertThatNoException().isThrownBy(() -> factory.createApplicationListener("test", SampleEvents.SampleEventsWithTransactionalAnnotation.class, m)); + } + private static void assertPhase(Method method, TransactionPhase expected) { assertThat(method).as("Method must not be null").isNotNull(); @@ -248,6 +276,29 @@ public void withTransactionalNotSupportedAnnotation(String data) { @Async @Transactional(propagation = Propagation.REQUIRES_NEW) public void withAsyncTransactionalAnnotation(String data) { } + + @Transactional + static class SampleEventsWithTransactionalAnnotation { + + @TransactionalEventListener + public void defaultPhase(String data) { + } + + @TransactionalEventListener + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void withTransactionalRequiresNewAnnotation(String data) { + } + + @TransactionalEventListener + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public void withTransactionalNotSupportedAnnotation(String data) { + } + + @TransactionalEventListener + @Async @Transactional(propagation = Propagation.REQUIRES_NEW) + public void withAsyncTransactionalAnnotation(String data) { + } + } } } diff --git a/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalEventListenerTests.java b/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalEventListenerTests.java index 193935eb296d..4cf080a82fe3 100644 --- a/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalEventListenerTests.java +++ b/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalEventListenerTests.java @@ -269,8 +269,7 @@ public void beforeCommit(boolean readOnly) { @Test void noTransaction() { - load(BeforeCommitTestListener.class, AfterCompletionTestListener.class, - AfterCompletionExplicitTestListener.class); + load(BeforeCommitTestListener.class, AfterCompletionTestListener.class, AfterCompletionExplicitTestListener.class); this.context.publishEvent("test"); getEventCollector().assertTotalEventsCount(0); } @@ -318,6 +317,24 @@ void noTransactionWithFallbackExecution() { getEventCollector().assertTotalEventsCount(4); } + @Test + void noTransactionManagementWithFallbackExecution() { + doLoad(PlainConfiguration.class, FallbackExecutionTestListener.class); + this.context.publishEvent("test"); + this.eventCollector.assertEvents(EventCollector.BEFORE_COMMIT, "test"); + this.eventCollector.assertEvents(EventCollector.AFTER_COMMIT, "test"); + this.eventCollector.assertEvents(EventCollector.AFTER_ROLLBACK, "test"); + this.eventCollector.assertEvents(EventCollector.AFTER_COMPLETION, "test"); + getEventCollector().assertTotalEventsCount(4); + } + + @Test + void noTransactionManagementWithoutFallbackExecution() { + doLoad(PlainConfiguration.class, BeforeCommitTestListener.class, AfterCommitMetaAnnotationTestListener.class); + this.context.publishEvent("test"); + this.eventCollector.assertNoEventReceived(); + } + @Test void conditionFoundOnTransactionalEventListener() { load(ImmediateTestListener.class); @@ -401,6 +418,31 @@ public TransactionTemplate transactionTemplate() { } + @Configuration + static class PlainConfiguration { + + @Bean + public EventCollector eventCollector() { + return new EventCollector(); + } + + @Bean + public TestBean testBean(ApplicationEventPublisher eventPublisher) { + return new TestBean(eventPublisher); + } + + @Bean + public CallCountingTransactionManager transactionManager() { + return new CallCountingTransactionManager(); + } + + @Bean + public TransactionTemplate transactionTemplate() { + return new TransactionTemplate(transactionManager()); + } + } + + @Configuration static class MulticasterWithCustomExecutor { @@ -569,7 +611,7 @@ interface TransactionalComponentTestInterface { } - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) @Component static class TransactionalComponentTestListenerWithInterface extends BaseTransactionalTestListener implements TransactionalComponentTestInterface { diff --git a/spring-tx/src/test/java/org/springframework/transaction/interceptor/BeanFactoryTransactionTests.java b/spring-tx/src/test/java/org/springframework/transaction/interceptor/BeanFactoryTransactionTests.java index f8fd380f6f79..727369f4d757 100644 --- a/spring-tx/src/test/java/org/springframework/transaction/interceptor/BeanFactoryTransactionTests.java +++ b/spring-tx/src/test/java/org/springframework/transaction/interceptor/BeanFactoryTransactionTests.java @@ -19,6 +19,7 @@ import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; @@ -52,63 +53,59 @@ * * @author Rod Johnson * @author Juergen Hoeller + * @author Sam Brannen * @since 23.04.2003 */ class BeanFactoryTransactionTests { - private DefaultListableBeanFactory factory; + private final DefaultListableBeanFactory factory = new DefaultListableBeanFactory(); @BeforeEach - void setUp() { - this.factory = new DefaultListableBeanFactory(); + void loadBeanDefinitions() { new XmlBeanDefinitionReader(this.factory).loadBeanDefinitions( new ClassPathResource("transactionalBeanFactory.xml", getClass())); } @Test - void testGetsAreNotTransactionalWithProxyFactory1() { - ITestBean testBean = (ITestBean) factory.getBean("proxyFactory1"); + void getsAreNotTransactionalWithProxyFactory1() { + ITestBean testBean = factory.getBean("proxyFactory1", ITestBean.class); assertThat(Proxy.isProxyClass(testBean.getClass())).as("testBean is a dynamic proxy").isTrue(); - boolean condition = testBean instanceof TransactionalProxy; - assertThat(condition).isFalse(); - doTestGetsAreNotTransactional(testBean); + assertThat(testBean).isNotInstanceOf(TransactionalProxy.class); + assertGetsAreNotTransactional(testBean); } @Test - void testGetsAreNotTransactionalWithProxyFactory2DynamicProxy() { + void getsAreNotTransactionalWithProxyFactory2DynamicProxy() { this.factory.preInstantiateSingletons(); - ITestBean testBean = (ITestBean) factory.getBean("proxyFactory2DynamicProxy"); + ITestBean testBean = factory.getBean("proxyFactory2DynamicProxy", ITestBean.class); assertThat(Proxy.isProxyClass(testBean.getClass())).as("testBean is a dynamic proxy").isTrue(); - boolean condition = testBean instanceof TransactionalProxy; - assertThat(condition).isTrue(); - doTestGetsAreNotTransactional(testBean); + assertThat(testBean).isInstanceOf(TransactionalProxy.class); + assertGetsAreNotTransactional(testBean); } @Test - void testGetsAreNotTransactionalWithProxyFactory2Cglib() { - ITestBean testBean = (ITestBean) factory.getBean("proxyFactory2Cglib"); + void getsAreNotTransactionalWithProxyFactory2Cglib() { + ITestBean testBean = factory.getBean("proxyFactory2Cglib", ITestBean.class); assertThat(AopUtils.isCglibProxy(testBean)).as("testBean is CGLIB advised").isTrue(); - boolean condition = testBean instanceof TransactionalProxy; - assertThat(condition).isTrue(); - doTestGetsAreNotTransactional(testBean); + assertThat(testBean).isInstanceOf(TransactionalProxy.class); + assertGetsAreNotTransactional(testBean); } @Test - void testProxyFactory2Lazy() { - ITestBean testBean = (ITestBean) factory.getBean("proxyFactory2Lazy"); + void proxyFactory2Lazy() { + ITestBean testBean = factory.getBean("proxyFactory2Lazy", ITestBean.class); assertThat(factory.containsSingleton("target")).isFalse(); assertThat(testBean.getAge()).isEqualTo(666); assertThat(factory.containsSingleton("target")).isTrue(); } @Test - void testCglibTransactionProxyImplementsNoInterfaces() { - ImplementsNoInterfaces ini = (ImplementsNoInterfaces) factory.getBean("cglibNoInterfaces"); + void cglibTransactionProxyImplementsNoInterfaces() { + ImplementsNoInterfaces ini = factory.getBean("cglibNoInterfaces", ImplementsNoInterfaces.class); assertThat(AopUtils.isCglibProxy(ini)).as("testBean is CGLIB advised").isTrue(); - boolean condition = ini instanceof TransactionalProxy; - assertThat(condition).isTrue(); + assertThat(ini).isInstanceOf(TransactionalProxy.class); String newName = "Gordon"; // Install facade @@ -121,49 +118,54 @@ void testCglibTransactionProxyImplementsNoInterfaces() { } @Test - void testGetsAreNotTransactionalWithProxyFactory3() { - ITestBean testBean = (ITestBean) factory.getBean("proxyFactory3"); - boolean condition = testBean instanceof DerivedTestBean; - assertThat(condition).as("testBean is a full proxy").isTrue(); - boolean condition1 = testBean instanceof TransactionalProxy; - assertThat(condition1).isTrue(); - InvocationCounterPointcut txnCounter = (InvocationCounterPointcut) factory.getBean("txnInvocationCounterPointcut"); - InvocationCounterInterceptor preCounter = (InvocationCounterInterceptor) factory.getBean("preInvocationCounterInterceptor"); - InvocationCounterInterceptor postCounter = (InvocationCounterInterceptor) factory.getBean("postInvocationCounterInterceptor"); - txnCounter.counter = 0; - preCounter.counter = 0; - postCounter.counter = 0; - doTestGetsAreNotTransactional(testBean); - // Can't assert it's equal to 4 as the pointcut may be optimized and only invoked once - assertThat(0 < txnCounter.counter && txnCounter.counter <= 4).isTrue(); - assertThat(preCounter.counter).isEqualTo(4); - assertThat(postCounter.counter).isEqualTo(4); + void getsAreNotTransactionalWithProxyFactory3() { + ITestBean testBean = factory.getBean("proxyFactory3", ITestBean.class); + assertThat(testBean).as("testBean is a full proxy") + .isInstanceOf(DerivedTestBean.class) + .isInstanceOf(TransactionalProxy.class); + + InvocationCounterPointcut txnPointcut = factory.getBean("txnInvocationCounterPointcut", InvocationCounterPointcut.class); + InvocationCounterInterceptor preInterceptor = factory.getBean("preInvocationCounterInterceptor", InvocationCounterInterceptor.class); + InvocationCounterInterceptor postInterceptor = factory.getBean("postInvocationCounterInterceptor", InvocationCounterInterceptor.class); + assertThat(txnPointcut.counter).as("txnPointcut").isGreaterThan(0); + assertThat(preInterceptor.counter).as("preInterceptor").isZero(); + assertThat(postInterceptor.counter).as("postInterceptor").isZero(); + + // Reset counters + txnPointcut.counter = 0; + preInterceptor.counter = 0; + postInterceptor.counter = 0; + + // Invokes: getAge() * 2 and setAge() * 1 --> 2 + 1 = 3 method invocations. + assertGetsAreNotTransactional(testBean); + + // The matches(Method, Class) method of the static transaction pointcut should not + // have been invoked for the actual invocation of the getAge() and setAge() methods. + assertThat(txnPointcut.counter).as("txnPointcut").isZero(); + + assertThat(preInterceptor.counter).as("preInterceptor").isEqualTo(3); + assertThat(postInterceptor.counter).as("postInterceptor").isEqualTo(3); } - private void doTestGetsAreNotTransactional(final ITestBean testBean) { + private void assertGetsAreNotTransactional(ITestBean testBean) { // Install facade PlatformTransactionManager ptm = mock(); PlatformTransactionManagerFacade.delegate = ptm; - assertThat(testBean.getAge()).as("Age should not be " + testBean.getAge()).isEqualTo(666); + assertThat(testBean.getAge()).as("Age").isEqualTo(666); - // Expect no methods + // Expect no interactions with the transaction manager. verifyNoInteractions(ptm); // Install facade expecting a call - final TransactionStatus ts = mock(); + AtomicBoolean invoked = new AtomicBoolean(); + TransactionStatus ts = mock(); ptm = new PlatformTransactionManager() { - private boolean invoked; @Override public TransactionStatus getTransaction(@Nullable TransactionDefinition def) throws TransactionException { - if (invoked) { - throw new IllegalStateException("getTransaction should not get invoked more than once"); - } - invoked = true; - if (!(def.getName().contains(DerivedTestBean.class.getName()) && def.getName().contains("setAge"))) { - throw new IllegalStateException( - "transaction name should contain class and method name: " + def.getName()); - } + assertThat(invoked.compareAndSet(false, true)) + .as("getTransaction() should not get invoked more than once").isTrue(); + assertThat(def.getName()).as("transaction name").contains(DerivedTestBean.class.getName(), "setAge"); return ts; } @Override @@ -177,14 +179,14 @@ public void rollback(TransactionStatus status) throws TransactionException { }; PlatformTransactionManagerFacade.delegate = ptm; - // TODO same as old age to avoid ordering effect for now - int age = 666; - testBean.setAge(age); - assertThat(testBean.getAge()).isEqualTo(age); + assertThat(invoked).as("getTransaction() invoked before setAge()").isFalse(); + testBean.setAge(42); + assertThat(invoked).as("getTransaction() invoked after setAge()").isTrue(); + assertThat(testBean.getAge()).as("Age").isEqualTo(42); } @Test - void testGetBeansOfTypeWithAbstract() { + void getBeansOfTypeWithAbstract() { Map beansOfType = factory.getBeansOfType(ITestBean.class, true, true); assertThat(beansOfType).isNotNull(); } @@ -193,24 +195,22 @@ void testGetBeansOfTypeWithAbstract() { * Check that we fail gracefully if the user doesn't set any transaction attributes. */ @Test - void testNoTransactionAttributeSource() { - assertThatExceptionOfType(FatalBeanException.class).isThrownBy(() -> { - DefaultListableBeanFactory bf = new DefaultListableBeanFactory(); - new XmlBeanDefinitionReader(bf).loadBeanDefinitions(new ClassPathResource("noTransactionAttributeSource.xml", getClass())); - bf.getBean("noTransactionAttributeSource"); - }); + void noTransactionAttributeSource() { + DefaultListableBeanFactory bf = new DefaultListableBeanFactory(); + new XmlBeanDefinitionReader(bf).loadBeanDefinitions(new ClassPathResource("noTransactionAttributeSource.xml", getClass())); + assertThatExceptionOfType(FatalBeanException.class).isThrownBy(() -> bf.getBean("noTransactionAttributeSource")); } /** * Test that we can set the target to a dynamic TargetSource. */ @Test - void testDynamicTargetSource() { + void dynamicTargetSource() { // Install facade CallCountingTransactionManager txMan = new CallCountingTransactionManager(); PlatformTransactionManagerFacade.delegate = txMan; - TestBean tb = (TestBean) factory.getBean("hotSwapped"); + TestBean tb = factory.getBean("hotSwapped", TestBean.class); assertThat(tb.getAge()).isEqualTo(666); int newAge = 557; tb.setAge(newAge); @@ -218,7 +218,7 @@ void testDynamicTargetSource() { TestBean target2 = new TestBean(); target2.setAge(65); - HotSwappableTargetSource ts = (HotSwappableTargetSource) factory.getBean("swapper"); + HotSwappableTargetSource ts = factory.getBean("swapper", HotSwappableTargetSource.class); ts.swap(target2); assertThat(tb.getAge()).isEqualTo(target2.getAge()); tb.setAge(newAge); diff --git a/spring-tx/src/test/java/org/springframework/transaction/interceptor/RuleBasedTransactionAttributeTests.java b/spring-tx/src/test/java/org/springframework/transaction/interceptor/RuleBasedTransactionAttributeTests.java index 3f4cffe2b824..b3ddbd0b5298 100644 --- a/spring-tx/src/test/java/org/springframework/transaction/interceptor/RuleBasedTransactionAttributeTests.java +++ b/spring-tx/src/test/java/org/springframework/transaction/interceptor/RuleBasedTransactionAttributeTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -107,8 +107,8 @@ private void doTestRuleForSelectiveRollbackOnChecked(RuleBasedTransactionAttribu @Test void ruleForCommitOnSubclassOfChecked() { List list = new ArrayList<>(); - // Note that it's important to ensure that we have this as - // a FQN: otherwise it will match everything! + // Note that it's important to ensure that we have this as a + // fully-qualified class name: otherwise it will match everything! list.add(new RollbackRuleAttribute("java.lang.Exception")); list.add(new NoRollbackRuleAttribute("IOException")); RuleBasedTransactionAttribute rta = new RuleBasedTransactionAttribute(TransactionDefinition.PROPAGATION_REQUIRED, list); diff --git a/spring-tx/src/test/java/org/springframework/transaction/interceptor/TransactionAttributeSourceEditorTests.java b/spring-tx/src/test/java/org/springframework/transaction/interceptor/TransactionAttributeSourceEditorTests.java index 808adfa35ad4..263b95681378 100644 --- a/spring-tx/src/test/java/org/springframework/transaction/interceptor/TransactionAttributeSourceEditorTests.java +++ b/spring-tx/src/test/java/org/springframework/transaction/interceptor/TransactionAttributeSourceEditorTests.java @@ -28,7 +28,7 @@ /** * Tests for {@link TransactionAttributeSourceEditor}. * - *

      Format is: {@code FQN.Method=tx attribute representation} + *

      Format is: {@code .=tx attribute representation} * * @author Rod Johnson * @author Sam Brannen diff --git a/spring-tx/src/test/java/org/springframework/transaction/interceptor/TransactionInterceptorTests.java b/spring-tx/src/test/java/org/springframework/transaction/interceptor/TransactionInterceptorTests.java index 3c7c352bba3c..de82fa7c9d08 100644 --- a/spring-tx/src/test/java/org/springframework/transaction/interceptor/TransactionInterceptorTests.java +++ b/spring-tx/src/test/java/org/springframework/transaction/interceptor/TransactionInterceptorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,7 +128,7 @@ void determineTransactionManagerWithNoBeanFactory() { PlatformTransactionManager transactionManager = mock(); TransactionInterceptor ti = transactionInterceptorWithTransactionManager(transactionManager, null); - assertThat(ti.determineTransactionManager(new DefaultTransactionAttribute())).isSameAs(transactionManager); + assertThat(ti.determineTransactionManager(new DefaultTransactionAttribute(), null)).isSameAs(transactionManager); } @Test @@ -136,7 +136,7 @@ void determineTransactionManagerWithNoBeanFactoryAndNoTransactionAttribute() { PlatformTransactionManager transactionManager = mock(); TransactionInterceptor ti = transactionInterceptorWithTransactionManager(transactionManager, null); - assertThat(ti.determineTransactionManager(null)).isSameAs(transactionManager); + assertThat(ti.determineTransactionManager(null, null)).isSameAs(transactionManager); } @Test @@ -144,7 +144,7 @@ void determineTransactionManagerWithNoTransactionAttribute() { BeanFactory beanFactory = mock(); TransactionInterceptor ti = simpleTransactionInterceptor(beanFactory); - assertThat(ti.determineTransactionManager(null)).isNull(); + assertThat(ti.determineTransactionManager(null, null)).isNull(); } @Test @@ -155,7 +155,7 @@ void determineTransactionManagerWithQualifierUnknown() { attribute.setQualifier("fooTransactionManager"); assertThatExceptionOfType(NoSuchBeanDefinitionException.class) - .isThrownBy(() -> ti.determineTransactionManager(attribute)) + .isThrownBy(() -> ti.determineTransactionManager(attribute, null)) .withMessageContaining("'fooTransactionManager'"); } @@ -170,7 +170,7 @@ void determineTransactionManagerWithQualifierAndDefault() { DefaultTransactionAttribute attribute = new DefaultTransactionAttribute(); attribute.setQualifier("fooTransactionManager"); - assertThat(ti.determineTransactionManager(attribute)).isSameAs(fooTransactionManager); + assertThat(ti.determineTransactionManager(attribute, null)).isSameAs(fooTransactionManager); } @Test @@ -185,7 +185,7 @@ void determineTransactionManagerWithQualifierAndDefaultName() { DefaultTransactionAttribute attribute = new DefaultTransactionAttribute(); attribute.setQualifier("fooTransactionManager"); - assertThat(ti.determineTransactionManager(attribute)).isSameAs(fooTransactionManager); + assertThat(ti.determineTransactionManager(attribute, null)).isSameAs(fooTransactionManager); } @Test @@ -199,7 +199,7 @@ void determineTransactionManagerWithEmptyQualifierAndDefaultName() { DefaultTransactionAttribute attribute = new DefaultTransactionAttribute(); attribute.setQualifier(""); - assertThat(ti.determineTransactionManager(attribute)).isSameAs(defaultTransactionManager); + assertThat(ti.determineTransactionManager(attribute, null)).isSameAs(defaultTransactionManager); } @Test @@ -211,11 +211,11 @@ void determineTransactionManagerWithQualifierSeveralTimes() { DefaultTransactionAttribute attribute = new DefaultTransactionAttribute(); attribute.setQualifier("fooTransactionManager"); - TransactionManager actual = ti.determineTransactionManager(attribute); + TransactionManager actual = ti.determineTransactionManager(attribute, null); assertThat(actual).isSameAs(txManager); // Call again, should be cached - TransactionManager actual2 = ti.determineTransactionManager(attribute); + TransactionManager actual2 = ti.determineTransactionManager(attribute, null); assertThat(actual2).isSameAs(txManager); verify(beanFactory, times(1)).containsBean("fooTransactionManager"); verify(beanFactory, times(1)).getBean("fooTransactionManager", TransactionManager.class); @@ -230,11 +230,11 @@ void determineTransactionManagerWithBeanNameSeveralTimes() { PlatformTransactionManager txManager = associateTransactionManager(beanFactory, "fooTransactionManager"); DefaultTransactionAttribute attribute = new DefaultTransactionAttribute(); - TransactionManager actual = ti.determineTransactionManager(attribute); + TransactionManager actual = ti.determineTransactionManager(attribute, null); assertThat(actual).isSameAs(txManager); // Call again, should be cached - TransactionManager actual2 = ti.determineTransactionManager(attribute); + TransactionManager actual2 = ti.determineTransactionManager(attribute, null); assertThat(actual2).isSameAs(txManager); verify(beanFactory, times(1)).getBean("fooTransactionManager", TransactionManager.class); } @@ -248,11 +248,11 @@ void determineTransactionManagerDefaultSeveralTimes() { given(beanFactory.getBean(TransactionManager.class)).willReturn(txManager); DefaultTransactionAttribute attribute = new DefaultTransactionAttribute(); - TransactionManager actual = ti.determineTransactionManager(attribute); + TransactionManager actual = ti.determineTransactionManager(attribute, null); assertThat(actual).isSameAs(txManager); // Call again, should be cached - TransactionManager actual2 = ti.determineTransactionManager(attribute); + TransactionManager actual2 = ti.determineTransactionManager(attribute, null); assertThat(actual2).isSameAs(txManager); verify(beanFactory, times(1)).getBean(TransactionManager.class); } @@ -296,6 +296,7 @@ private TransactionInterceptor simpleTransactionInterceptor(BeanFactory beanFact private PlatformTransactionManager associateTransactionManager(BeanFactory beanFactory, String name) { PlatformTransactionManager transactionManager = mock(); given(beanFactory.containsBean(name)).willReturn(true); + given(beanFactory.isTypeMatch(name, TransactionManager.class)).willReturn(true); given(beanFactory.getBean(name, TransactionManager.class)).willReturn(transactionManager); return transactionManager; } diff --git a/spring-tx/src/test/java/org/springframework/transaction/support/TransactionSupportTests.java b/spring-tx/src/test/java/org/springframework/transaction/support/TransactionSupportTests.java index d5c4f5d20e99..da661fdd9c70 100644 --- a/spring-tx/src/test/java/org/springframework/transaction/support/TransactionSupportTests.java +++ b/spring-tx/src/test/java/org/springframework/transaction/support/TransactionSupportTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-web/spring-web.gradle b/spring-web/spring-web.gradle index b1fffdb0c610..7585e176b996 100644 --- a/spring-web/spring-web.gradle +++ b/spring-web/spring-web.gradle @@ -15,12 +15,13 @@ dependencies { optional("com.fasterxml.jackson.dataformat:jackson-dataformat-cbor") optional("com.fasterxml.jackson.dataformat:jackson-dataformat-smile") optional("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") + optional("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") optional("com.fasterxml.woodstox:woodstox-core") optional("com.google.code.gson:gson") optional("com.google.protobuf:protobuf-java-util") optional("com.rometools:rome") optional("com.squareup.okhttp3:okhttp") - optional("io.reactivex.rxjava3:rxjava") + optional("io.micrometer:context-propagation") optional("io.netty:netty-buffer") optional("io.netty:netty-handler") optional("io.netty:netty-codec-http") @@ -31,6 +32,7 @@ dependencies { optional("io.netty:netty5-transport") optional("io.projectreactor.netty:reactor-netty-http") optional("io.projectreactor.netty:reactor-netty5-http") + optional("io.reactivex.rxjava3:rxjava") optional("io.undertow:undertow-core") optional("jakarta.el:jakarta.el-api") optional("jakarta.faces:jakarta.faces-api") @@ -70,6 +72,7 @@ dependencies { because("needed by Netty's SelfSignedCertificate on JDK 15+") } testFixturesImplementation("org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jetty-server") + testFixturesImplementation("org.eclipse.jetty.websocket:jetty-websocket-jetty-server") testImplementation(project(":spring-core-test")) testImplementation(testFixtures(project(":spring-beans"))) testImplementation(testFixtures(project(":spring-context"))) diff --git a/spring-web/src/jmh/java/org/springframework/http/support/HeadersAdapterBenchmark.java b/spring-web/src/jmh/java/org/springframework/http/support/HeadersAdapterBenchmark.java index e2b3946cf3c5..876572d8772d 100644 --- a/spring-web/src/jmh/java/org/springframework/http/support/HeadersAdapterBenchmark.java +++ b/spring-web/src/jmh/java/org/springframework/http/support/HeadersAdapterBenchmark.java @@ -74,8 +74,8 @@ public static class BenchmarkData { public MultiValueMap headers; public Function, Set>>> entriesProvider; - //Uncomment the following line and comment the similar line for setupImplementationBaseline below - //to benchmark current implementations + // Uncomment the following line and comment the similar line for setupImplementationBaseline below + // to benchmark current implementations @Setup(Level.Trial) public void initImplementationNew() { this.entriesProvider = map -> new HttpHeaders(map).headerSet(); @@ -85,7 +85,7 @@ public void initImplementationNew() { case "HttpComponents" -> new HttpComponentsHeadersAdapter(new HttpGet("https://example.com")); case "Netty5" -> new Netty5HeadersAdapter(io.netty5.handler.codec.http.headers.HttpHeaders.newHeaders()); case "Jetty" -> new JettyHeadersAdapter(HttpFields.build()); - //FIXME tomcat/undertow implementations (in another package) + // FIXME tomcat/undertow implementations (in another package) // case "Tomcat" -> new TomcatHeadersAdapter(new MimeHeaders()); // case "Undertow" -> new UndertowHeadersAdapter(new HeaderMap()); default -> throw new IllegalArgumentException("Unsupported implementation: " + this.implementation); @@ -93,8 +93,8 @@ public void initImplementationNew() { initHeaders(); } - //Uncomment the following line and comment the similar line for setupImplementationNew above - //to benchmark old implementations + // Uncomment the following line and comment the similar line for setupImplementationNew above + // to benchmark old implementations // @Setup(Level.Trial) public void setupImplementationBaseline() { this.entriesProvider = MultiValueMap::entrySet; diff --git a/spring-web/src/main/java/org/springframework/http/ContentDisposition.java b/spring-web/src/main/java/org/springframework/http/ContentDisposition.java index 7697538739d3..cf591ef8d3dc 100644 --- a/spring-web/src/main/java/org/springframework/http/ContentDisposition.java +++ b/spring-web/src/main/java/org/springframework/http/ContentDisposition.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author 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,6 +25,7 @@ import java.util.Base64; import java.util.BitSet; import java.util.List; +import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -354,7 +355,7 @@ public static ContentDisposition parse(String contentDisposition) { String part = parts.get(i); int eqIndex = part.indexOf('='); if (eqIndex != -1) { - String attribute = part.substring(0, eqIndex); + String attribute = part.substring(0, eqIndex).toLowerCase(Locale.ROOT); String value = (part.startsWith("\"", eqIndex + 1) && part.endsWith("\"") ? part.substring(eqIndex + 2, part.length() - 1) : part.substring(eqIndex + 1)); @@ -698,7 +699,7 @@ public interface Builder { * Set the value of the {@literal filename} parameter. The given * filename will be formatted as quoted-string, as defined in RFC 2616, * section 2.2, and any quote characters within the filename value will - * be escaped with a backslash, e.g. {@code "foo\"bar.txt"} becomes + * be escaped with a backslash, for example, {@code "foo\"bar.txt"} becomes * {@code "foo\\\"bar.txt"}. */ Builder filename(@Nullable String filename); diff --git a/spring-web/src/main/java/org/springframework/http/ETag.java b/spring-web/src/main/java/org/springframework/http/ETag.java index 6f7027b5ef0d..35c767eccd08 100644 --- a/spring-web/src/main/java/org/springframework/http/ETag.java +++ b/spring-web/src/main/java/org/springframework/http/ETag.java @@ -47,6 +47,44 @@ public boolean isWildcard() { return (this == WILDCARD); } + /** + * Perform a strong or weak comparison to another {@link ETag}. + * @param other the ETag to compare to + * @param strong whether to perform strong or weak comparison + * @return whether there is a match or not + * @since 6.2 + * @see RFC 9110, Section 8.8.3.2 + */ + public boolean compare(ETag other, boolean strong) { + if (!StringUtils.hasLength(tag()) || !StringUtils.hasLength(other.tag())) { + return false; + } + + if (strong && (weak() || other.weak())) { + return false; + } + + return tag().equals(other.tag()); + } + + @Override + public boolean equals(Object other) { + return (this == other || + (other instanceof ETag oet && this.tag.equals(oet.tag) && this.weak == oet.weak)); + } + + @Override + public int hashCode() { + int result = this.tag.hashCode(); + result = 31 * result + Boolean.hashCode(this.weak); + return result; + } + + @Override + public String toString() { + return formattedTag(); + } + /** * Return the fully formatted tag including "W/" prefix and quotes. */ @@ -57,11 +95,23 @@ public String formattedTag() { return (this.weak ? "W/" : "") + "\"" + this.tag + "\""; } - @Override - public String toString() { - return formattedTag(); - } + /** + * Create an {@link ETag} instance from a String representation. + * @param rawValue the formatted ETag value + * @return the created instance + * @since 6.2 + */ + public static ETag create(String rawValue) { + boolean weak = rawValue.startsWith("W/"); + if (weak) { + rawValue = rawValue.substring(2); + } + if (rawValue.length() > 2 && rawValue.startsWith("\"") && rawValue.endsWith("\"")) { + rawValue = rawValue.substring(1, rawValue.length() - 1); + } + return new ETag(rawValue, weak); + } /** * Parse entity tags from an "If-Match" or "If-None-Match" header. @@ -134,6 +184,26 @@ public static List parse(String source) { return result; } + /** + * Add quotes around the ETag value if not present already. + * @param tag the ETag value + * @return the resulting, quoted value + * @since 6.2 + */ + public static String quoteETagIfNecessary(String tag) { + if (tag.startsWith("W/\"")) { + if (tag.length() > 3 && tag.endsWith("\"")) { + return tag; + } + } + else if (tag.startsWith("\"")) { + if (tag.length() > 1 && tag.endsWith("\"")) { + return tag; + } + } + return ("\"" + tag + "\""); + } + private enum State { 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 d7e07d52c7bc..5d895e4a368d 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -36,7 +36,6 @@ import java.util.Collection; import java.util.Collections; import java.util.Iterator; -import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -84,6 +83,10 @@ * over the old instance's {@code entrySet()} and using * {@link #addAll(String, List)} rather than {@link #put(String, List)}. * + *

      This class is meant to reference "well-known" headers supported by Spring + * Framework. If your application or library relies on other headers defined in RFCs, + * please use methods that accept the header name as a parameter. + * * @author Arjen Poutsma * @author Sebastien Deleuze * @author Brian Clozel @@ -454,7 +457,18 @@ public HttpHeaders() { */ public HttpHeaders(MultiValueMap headers) { Assert.notNull(headers, "MultiValueMap must not be null"); - this.headers = headers; + if (headers == EMPTY) { + this.headers = CollectionUtils.toMultiValueMap(new LinkedCaseInsensitiveMap<>(8, Locale.ENGLISH)); + } + else if (headers instanceof HttpHeaders httpHeaders) { + while (httpHeaders.headers instanceof HttpHeaders wrapped) { + httpHeaders = wrapped; + } + this.headers = httpHeaders.headers; + } + else { + this.headers = headers; + } } @@ -514,7 +528,20 @@ public void setAcceptLanguage(List languages) { */ public List getAcceptLanguage() { String value = getFirst(ACCEPT_LANGUAGE); - return (StringUtils.hasText(value) ? Locale.LanguageRange.parse(value) : Collections.emptyList()); + if (StringUtils.hasText(value)) { + try { + return Locale.LanguageRange.parse(value); + } + catch (IllegalArgumentException ignored) { + String[] tokens = StringUtils.tokenizeToStringArray(value, ","); + for (int i = 0; i < tokens.length; i++) { + tokens[i] = StringUtils.trimTrailingCharacter(tokens[i], ';'); + } + value = StringUtils.arrayToCommaDelimitedString(tokens); + return Locale.LanguageRange.parse(value); + } + } + return Collections.emptyList(); } /** @@ -768,7 +795,7 @@ public Set getAllow() { String value = getFirst(ALLOW); if (StringUtils.hasLength(value)) { String[] tokens = StringUtils.tokenizeToStringArray(value, ","); - Set result = new LinkedHashSet<>(tokens.length); + Set result = CollectionUtils.newLinkedHashSet(tokens.length); for (String token : tokens) { HttpMethod method = HttpMethod.valueOf(token); result.add(method); @@ -974,8 +1001,13 @@ public Locale getContentLanguage() { /** * Set the length of the body in bytes, as specified by the * {@code Content-Length} header. + * @param contentLength content length (greater than or equal to zero) + * @throws IllegalArgumentException if the content length is negative */ public void setContentLength(long contentLength) { + if (contentLength < 0) { + throw new IllegalArgumentException("Content-Length must be a non-negative number"); + } set(CONTENT_LENGTH, Long.toString(contentLength)); } @@ -1058,11 +1090,9 @@ public long getDate() { /** * Set the (new) entity tag of the body, as specified by the {@code ETag} header. */ - public void setETag(@Nullable String etag) { - if (etag != null) { - Assert.isTrue(etag.startsWith("\"") || etag.startsWith("W/\""), "ETag does not start with W/\" or \""); - Assert.isTrue(etag.endsWith("\""), "ETag does not end with \""); - set(ETAG, etag); + public void setETag(@Nullable String tag) { + if (tag != null) { + set(ETAG, ETag.quoteETagIfNecessary(tag)); } else { remove(ETAG); @@ -1133,7 +1163,7 @@ public void setHost(@Nullable InetSocketAddress host) { set(HOST, value); } else { - remove(HOST, null); + remove(HOST); } } @@ -1415,7 +1445,7 @@ public String getUpgrade() { } /** - * Set the request header names (e.g. "Accept-Language") for which the + * Set the request header names (for example, "Accept-Language") for which the * response is subject to content negotiation and variances based on the * value of those request headers. * @param requestHeaders the request header names @@ -1765,6 +1795,10 @@ public Map toSingleValueMap() { return this.headers.toSingleValueMap(); } + @Override + public Map asSingleValueMap() { + return this.headers.asSingleValueMap(); + } // Map implementation @@ -1920,7 +1954,7 @@ public static HttpHeaders readOnlyHttpHeaders(MultiValueMap head * Apply a read-only {@code HttpHeaders} wrapper around the given headers, if necessary. *

      Also caches the parsed representations of the "Accept" and "Content-Type" headers. * @param headers the headers to expose - * @return a read-only variant of the headers, or the original headers as-is + * @return a read-only variant of the headers, or the original headers as-is if already read-only */ public static HttpHeaders readOnlyHttpHeaders(HttpHeaders headers) { Assert.notNull(headers, "HttpHeaders must not be null"); @@ -1930,19 +1964,16 @@ public static HttpHeaders readOnlyHttpHeaders(HttpHeaders headers) { /** * Remove any read-only wrapper that may have been previously applied around * the given headers via {@link #readOnlyHttpHeaders(HttpHeaders)}. + *

      Once the writable instance is mutated, the read-only instance is likely + * to be out of sync and should be discarded. * @param headers the headers to expose * @return a writable variant of the headers, or the original headers as-is * @since 5.1.1 + * @deprecated as of 6.2 in favor of {@link #HttpHeaders(MultiValueMap)}. */ + @Deprecated(since = "6.2", forRemoval = true) public static HttpHeaders writableHttpHeaders(HttpHeaders headers) { - Assert.notNull(headers, "HttpHeaders must not be null"); - if (headers == EMPTY) { - return new HttpHeaders(); - } - while (headers.headers instanceof HttpHeaders wrapped) { - headers = wrapped; - } - return new HttpHeaders(headers.headers); + return new HttpHeaders(headers); } /** @@ -1967,6 +1998,7 @@ public static String formatHeaders(MultiValueMap headers) { return headerNames.stream() .map(headerName -> { List values = headers.get(headerName); + Assert.notNull(values, "Expected at least one value for header " + headerName); return headerName + ":" + (values.size() == 1 ? "\"" + values.get(0) + "\"" : values.stream().map(s -> "\"" + s + "\"").collect(Collectors.joining(", "))); diff --git a/spring-web/src/main/java/org/springframework/http/HttpMethod.java b/spring-web/src/main/java/org/springframework/http/HttpMethod.java index 12f77ffe4bc6..e10185ecb462 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpMethod.java +++ b/spring-web/src/main/java/org/springframework/http/HttpMethod.java @@ -129,7 +129,7 @@ public static HttpMethod valueOf(String method) { /** - * Return the name of this method, e.g. "GET", "POST". + * Return the name of this method, for example, "GET", "POST". */ public String name() { return this.name; diff --git a/spring-web/src/main/java/org/springframework/http/HttpRequest.java b/spring-web/src/main/java/org/springframework/http/HttpRequest.java index 62ea73fad5d0..d9a592512894 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/HttpRequest.java @@ -17,6 +17,7 @@ package org.springframework.http; import java.net.URI; +import java.util.Map; /** * Represents an HTTP request message, consisting of a @@ -41,4 +42,10 @@ public interface HttpRequest extends HttpMessage { */ URI getURI(); + /** + * Return a mutable map of request attributes for this request. + * @since 6.2 + */ + Map getAttributes(); + } diff --git a/spring-web/src/main/java/org/springframework/http/HttpStatusCode.java b/spring-web/src/main/java/org/springframework/http/HttpStatusCode.java index 3275eb8d9661..20a0c243fc6e 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpStatusCode.java +++ b/spring-web/src/main/java/org/springframework/http/HttpStatusCode.java @@ -79,7 +79,7 @@ public sealed interface HttpStatusCode extends Serializable permits DefaultHttpS /** * Whether this {@code HttpStatusCode} shares the same integer {@link #value() value} as the other status code. *

      Useful for comparisons that take deprecated aliases into account or compare arbitrary implementations - * of {@code HttpStatusCode} (e.g. in place of {@link HttpStatus#equals(Object) HttpStatus enum equality}). + * of {@code HttpStatusCode} (for example, in place of {@link HttpStatus#equals(Object) HttpStatus enum equality}). * @param other the other {@code HttpStatusCode} to compare * @return true if the two {@code HttpStatusCode} objects share the same integer {@code value()}, false otherwise * @since 6.0.5 diff --git a/spring-web/src/main/java/org/springframework/http/InvalidMediaTypeException.java b/spring-web/src/main/java/org/springframework/http/InvalidMediaTypeException.java index ec324b8353d0..905e95a0d037 100644 --- a/spring-web/src/main/java/org/springframework/http/InvalidMediaTypeException.java +++ b/spring-web/src/main/java/org/springframework/http/InvalidMediaTypeException.java @@ -16,6 +16,7 @@ package org.springframework.http; +import org.springframework.lang.Nullable; import org.springframework.util.InvalidMimeTypeException; /** @@ -36,8 +37,8 @@ public class InvalidMediaTypeException extends IllegalArgumentException { * @param mediaType the offending media type * @param message a detail message indicating the invalid part */ - public InvalidMediaTypeException(String mediaType, String message) { - super("Invalid media type \"" + mediaType + "\": " + message); + public InvalidMediaTypeException(String mediaType, @Nullable String message) { + super(message != null ? "Invalid media type \"" + mediaType + "\": " + message : "Invalid media type \"" + mediaType); this.mediaType = mediaType; } diff --git a/spring-web/src/main/java/org/springframework/http/MediaType.java b/spring-web/src/main/java/org/springframework/http/MediaType.java index f89063d8df00..746adf65e168 100644 --- a/spring-web/src/main/java/org/springframework/http/MediaType.java +++ b/spring-web/src/main/java/org/springframework/http/MediaType.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,12 +39,17 @@ * A subclass of {@link MimeType} that adds support for quality parameters * as defined in the HTTP specification. * + *

      This class is meant to reference media types supported by Spring Framework. + * If your application or library relies on other media types defined in RFCs, + * please use {@link #parseMediaType(String)} or a custom utility class. + * * @author Arjen Poutsma * @author Juergen Hoeller * @author Rossen Stoyanchev * @author Sebastien Deleuze * @author Kazuki Shimizu * @author Sam Brannen + * @author Hyoungjune Kim * @since 3.0 * @see * HTTP 1.1: Semantics and Content, section 3.1.1.1 @@ -285,7 +290,7 @@ public class MediaType extends MimeType implements Serializable { * @deprecated as of 5.3 since it originates from the W3C Activity Streams * specification which has a more specific purpose and has been since * replaced with a different mime type. Use {@link #APPLICATION_NDJSON} as - * a replacement or any other line-delimited JSON format (e.g. JSON Lines, + * a replacement or any other line-delimited JSON format (for example, JSON Lines, * JSON Text Sequences). */ @Deprecated @@ -311,6 +316,18 @@ public class MediaType extends MimeType implements Serializable { */ public static final String APPLICATION_XML_VALUE = "application/xml"; + /** + * Public constant media type for {@code application/yaml}. + * @since 6.2 + */ + public static final MediaType APPLICATION_YAML; + + /** + * A String equivalent of {@link MediaType#APPLICATION_YAML}. + * @since 6.2 + */ + public static final String APPLICATION_YAML_VALUE = "application/yaml"; + /** * Public constant media type for {@code image/gif}. */ @@ -434,7 +451,7 @@ public class MediaType extends MimeType implements Serializable { static { - // Not using "valueOf' to avoid static init cost + // Not using "valueOf" to avoid static init cost ALL = new MediaType(MimeType.WILDCARD_TYPE, MimeType.WILDCARD_TYPE); APPLICATION_ATOM_XML = new MediaType("application", "atom+xml"); APPLICATION_CBOR = new MediaType("application", "cbor"); @@ -454,6 +471,7 @@ public class MediaType extends MimeType implements Serializable { APPLICATION_STREAM_JSON = new MediaType("application", "stream+json"); APPLICATION_XHTML_XML = new MediaType("application", "xhtml+xml"); APPLICATION_XML = new MediaType("application", "xml"); + APPLICATION_YAML = new MediaType("application", "yaml"); IMAGE_GIF = new MediaType("image", "gif"); IMAGE_JPEG = new MediaType("image", "jpeg"); IMAGE_PNG = new MediaType("image", "png"); @@ -718,7 +736,7 @@ public MediaType removeQualityValue() { /** * Parse the given String value into a {@code MediaType} object, * with this method name following the 'valueOf' naming convention - * (as supported by {@link org.springframework.core.convert.ConversionService}. + * (as supported by {@link org.springframework.core.convert.ConversionService}). * @param value the string to parse * @throws InvalidMediaTypeException if the media type value cannot be parsed * @see #parseMediaType(String) @@ -853,7 +871,7 @@ public static String toString(Collection mediaTypes) { *

      audio/basic == text/html
      *
      audio/basic == audio/wave
      * @param mediaTypes the list of media types to be sorted - * @deprecated As of 6.0, in favor of {@link MimeTypeUtils#sortBySpecificity(List)} + * @deprecated as of 6.0, in favor of {@link MimeTypeUtils#sortBySpecificity(List)} */ @Deprecated(since = "6.0", forRemoval = true) public static void sortBySpecificity(List mediaTypes) { @@ -882,7 +900,7 @@ public static void sortBySpecificity(List mediaTypes) { * * @param mediaTypes the list of media types to be sorted * @see #getQualityValue() - * @deprecated As of 6.0, with no direct replacement + * @deprecated as of 6.0, with no direct replacement */ @Deprecated(since = "6.0", forRemoval = true) public static void sortByQualityValue(List mediaTypes) { @@ -895,9 +913,9 @@ public static void sortByQualityValue(List mediaTypes) { /** * Sorts the given list of {@code MediaType} objects by specificity as the * primary criteria and quality value the secondary. - * @deprecated As of 6.0, in favor of {@link MimeTypeUtils#sortBySpecificity(List)} + * @deprecated as of 6.0, in favor of {@link MimeTypeUtils#sortBySpecificity(List)} */ - @Deprecated(since = "6.0") + @Deprecated(since = "6.0", forRemoval = true) public static void sortBySpecificityAndQuality(List mediaTypes) { Assert.notNull(mediaTypes, "'mediaTypes' must not be null"); if (mediaTypes.size() > 1) { @@ -908,7 +926,7 @@ public static void sortBySpecificityAndQuality(List mediaTypes) { /** * Comparator used by {@link #sortByQualityValue(List)}. - * @deprecated As of 6.0, with no direct replacement + * @deprecated as of 6.0, with no direct replacement */ @Deprecated(since = "6.0", forRemoval = true) public static final Comparator QUALITY_VALUE_COMPARATOR = (mediaType1, mediaType2) -> { @@ -948,7 +966,7 @@ else if (!mediaType1.getSubtype().equals(mediaType2.getSubtype())) { // audio/b /** * Comparator used by {@link #sortBySpecificity(List)}. - * @deprecated As of 6.0, with no direct replacement + * @deprecated as of 6.0, with no direct replacement */ @Deprecated(since = "6.0", forRemoval = true) @SuppressWarnings("removal") diff --git a/spring-web/src/main/java/org/springframework/http/MediaTypeEditor.java b/spring-web/src/main/java/org/springframework/http/MediaTypeEditor.java index 8a6971909fbb..092448b29a60 100644 --- a/spring-web/src/main/java/org/springframework/http/MediaTypeEditor.java +++ b/spring-web/src/main/java/org/springframework/http/MediaTypeEditor.java @@ -23,7 +23,7 @@ /** * {@link java.beans.PropertyEditor Editor} for {@link MediaType} * descriptors, to automatically convert {@code String} specifications - * (e.g. {@code "text/html"}) to {@code MediaType} properties. + * (for example, {@code "text/html"}) to {@code MediaType} properties. * * @author Juergen Hoeller * @since 3.0 diff --git a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java index 4b38fd55ca98..e0ed1bee4cc9 100644 --- a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java +++ b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author 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.http; +import java.io.Serializable; import java.net.URI; import java.util.LinkedHashMap; import java.util.Map; @@ -37,7 +38,7 @@ * *

      For an extended response, an application can also create a subclass with * additional properties. Subclasses can use the protected copy constructor to - * re-create an existing {@code ProblemDetail} instance as the subclass, e.g. + * re-create an existing {@code ProblemDetail} instance as the subclass, for example, * from an {@code @ControllerAdvice} such as * {@link org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler} or * {@link org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler}. @@ -49,7 +50,9 @@ * @see org.springframework.web.ErrorResponse * @see org.springframework.web.ErrorResponseException */ -public class ProblemDetail { +public class ProblemDetail implements Serializable { + + private static final long serialVersionUID = 3307761915842206538L; private static final URI BLANK_TYPE = URI.create("about:blank"); diff --git a/spring-web/src/main/java/org/springframework/http/ReactiveHttpOutputMessage.java b/spring-web/src/main/java/org/springframework/http/ReactiveHttpOutputMessage.java index b15f84c89880..f55ae517df9e 100644 --- a/spring-web/src/main/java/org/springframework/http/ReactiveHttpOutputMessage.java +++ b/spring-web/src/main/java/org/springframework/http/ReactiveHttpOutputMessage.java @@ -46,7 +46,7 @@ public interface ReactiveHttpOutputMessage extends HttpMessage { /** * Register an action to apply just before the HttpOutputMessage is committed. *

      Note: the supplied action must be properly deferred, - * e.g. via {@link Mono#defer} or {@link Mono#fromRunnable}, to ensure it's + * for example, via {@link Mono#defer} or {@link Mono#fromRunnable}, to ensure it's * executed in the right order, relative to other actions. * @param action the action to apply */ diff --git a/spring-web/src/main/java/org/springframework/http/ReadOnlyHttpHeaders.java b/spring-web/src/main/java/org/springframework/http/ReadOnlyHttpHeaders.java index 0298fa2dff3d..b667207f936c 100644 --- a/spring-web/src/main/java/org/springframework/http/ReadOnlyHttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/ReadOnlyHttpHeaders.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +31,8 @@ /** * {@code HttpHeaders} object that can only be read, not written to. + *

      This caches the parsed representations of the "Accept" and "Content-Type" headers + * and will get out of sync with the backing map it is mutated at runtime. * * @author Brian Clozel * @author Sam Brannen @@ -120,6 +122,11 @@ public Map toSingleValueMap() { return Collections.unmodifiableMap(this.headers.toSingleValueMap()); } + @Override + public Map asSingleValueMap() { + return Collections.unmodifiableMap(this.headers.asSingleValueMap()); + } + @Override public Set keySet() { return Collections.unmodifiableSet(this.headers.keySet()); diff --git a/spring-web/src/main/java/org/springframework/http/RequestEntity.java b/spring-web/src/main/java/org/springframework/http/RequestEntity.java index 364a1373bfa3..d387735f8b7a 100644 --- a/spring-web/src/main/java/org/springframework/http/RequestEntity.java +++ b/spring-web/src/main/java/org/springframework/http/RequestEntity.java @@ -205,8 +205,8 @@ public boolean equals(@Nullable Object other) { if (!super.equals(other)) { return false; } - RequestEntity otherEntity = (RequestEntity) other; - return (ObjectUtils.nullSafeEquals(this.method, otherEntity.method) && + return (other instanceof RequestEntity otherEntity && + ObjectUtils.nullSafeEquals(this.method, otherEntity.method) && ObjectUtils.nullSafeEquals(this.url, otherEntity.url)); } @@ -736,8 +736,8 @@ public boolean equals(@Nullable Object other) { if (!super.equals(other)) { return false; } - UriTemplateRequestEntity otherEntity = (UriTemplateRequestEntity) other; - return (ObjectUtils.nullSafeEquals(this.uriTemplate, otherEntity.uriTemplate) && + return (other instanceof UriTemplateRequestEntity otherEntity && + ObjectUtils.nullSafeEquals(this.uriTemplate, otherEntity.uriTemplate) && ObjectUtils.nullSafeEquals(this.uriVarsArray, otherEntity.uriVarsArray) && ObjectUtils.nullSafeEquals(this.uriVarsMap, otherEntity.uriVarsMap)); } diff --git a/spring-web/src/main/java/org/springframework/http/ResponseCookie.java b/spring-web/src/main/java/org/springframework/http/ResponseCookie.java index 485550614fc2..67d8dbea3908 100644 --- a/spring-web/src/main/java/org/springframework/http/ResponseCookie.java +++ b/spring-web/src/main/java/org/springframework/http/ResponseCookie.java @@ -47,6 +47,8 @@ public final class ResponseCookie extends HttpCookie { private final boolean httpOnly; + private final boolean partitioned; + @Nullable private final String sameSite; @@ -55,7 +57,7 @@ public final class ResponseCookie extends HttpCookie { * Private constructor. See {@link #from(String, String)}. */ private ResponseCookie(String name, @Nullable String value, Duration maxAge, @Nullable String domain, - @Nullable String path, boolean secure, boolean httpOnly, @Nullable String sameSite) { + @Nullable String path, boolean secure, boolean httpOnly, boolean partitioned, @Nullable String sameSite) { super(name, value); Assert.notNull(maxAge, "Max age must not be null"); @@ -65,6 +67,7 @@ private ResponseCookie(String name, @Nullable String value, Duration maxAge, @Nu this.path = path; this.secure = secure; this.httpOnly = httpOnly; + this.partitioned = partitioned; this.sameSite = sameSite; Rfc6265Utils.validateCookieName(name); @@ -116,6 +119,15 @@ public boolean isHttpOnly() { return this.httpOnly; } + /** + * Return {@code true} if the cookie has the "Partitioned" attribute. + * @since 6.2 + * @see The Partitioned attribute spec + */ + public boolean isPartitioned() { + return this.partitioned; + } + /** * Return the cookie "SameSite" attribute, or {@code null} if not set. *

      This limits the scope of the cookie such that it will only be attached to @@ -139,6 +151,7 @@ public ResponseCookieBuilder mutate() { .path(this.path) .secure(this.secure) .httpOnly(this.httpOnly) + .partitioned(this.partitioned) .sameSite(this.sameSite); } @@ -180,6 +193,9 @@ public String toString() { if (this.httpOnly) { sb.append("; HttpOnly"); } + if (this.partitioned) { + sb.append("; Partitioned"); + } if (StringUtils.hasText(this.sameSite)) { sb.append("; SameSite=").append(this.sameSite); } @@ -213,7 +229,7 @@ public static ResponseCookieBuilder from(final String name, final String value) /** * Factory method to obtain a builder for a server-defined cookie. Unlike * {@link #from(String, String)} this option assumes input from a remote - * server, which can be handled more leniently, e.g. ignoring an empty domain + * server, which can be handled more leniently, for example, ignoring an empty domain * name with double quotes. * @param name the cookie name * @param value the cookie value @@ -272,6 +288,13 @@ public interface ResponseCookieBuilder { */ ResponseCookieBuilder httpOnly(boolean httpOnly); + /** + * Add the "Partitioned" attribute to the cookie. + * @since 6.2 + * @see The Partitioned attribute spec + */ + ResponseCookieBuilder partitioned(boolean partitioned); + /** * Add the "SameSite" attribute to the cookie. *

      This limits the scope of the cookie such that it will only be @@ -397,6 +420,8 @@ private static class DefaultResponseCookieBuilder implements ResponseCookieBuild private boolean httpOnly; + private boolean partitioned; + @Nullable private String sameSite; @@ -461,6 +486,12 @@ public ResponseCookieBuilder httpOnly(boolean httpOnly) { return this; } + @Override + public ResponseCookieBuilder partitioned(boolean partitioned) { + this.partitioned = partitioned; + return this; + } + @Override public ResponseCookieBuilder sameSite(@Nullable String sameSite) { this.sameSite = sameSite; @@ -470,7 +501,7 @@ public ResponseCookieBuilder sameSite(@Nullable String sameSite) { @Override public ResponseCookie build() { return new ResponseCookie(this.name, this.value, this.maxAge, - this.domain, this.path, this.secure, this.httpOnly, this.sameSite); + this.domain, this.path, this.secure, this.httpOnly, this.partitioned, this.sameSite); } } 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 2d60ec47345d..a6e16405e88d 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-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -162,8 +162,7 @@ public boolean equals(@Nullable Object other) { if (!super.equals(other)) { return false; } - ResponseEntity otherEntity = (ResponseEntity) other; - return ObjectUtils.nullSafeEquals(this.status, otherEntity.status); + return (other instanceof ResponseEntity otherEntity && ObjectUtils.nullSafeEquals(this.status, otherEntity.status)); } @Override @@ -459,7 +458,7 @@ public interface HeadersBuilder> { B cacheControl(CacheControl cacheControl); /** - * Configure one or more request header names (e.g. "Accept-Language") to + * Configure one or more request header names (for example, "Accept-Language") to * add to the "Vary" response header to inform clients that the response is * subject to content negotiation and variances based on the value of the * given request headers. The configured request header names are added only @@ -569,16 +568,8 @@ public BodyBuilder contentType(MediaType contentType) { } @Override - public BodyBuilder eTag(@Nullable String etag) { - if (etag != null) { - if (!etag.startsWith("\"") && !etag.startsWith("W/\"")) { - etag = "\"" + etag; - } - if (!etag.endsWith("\"")) { - etag = etag + "\""; - } - } - this.headers.setETag(etag); + public BodyBuilder eTag(@Nullable String tag) { + this.headers.setETag(tag); return this; } diff --git a/spring-web/src/main/java/org/springframework/http/client/AbstractClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/AbstractClientHttpRequest.java index f964e66e80d1..5404493a343f 100644 --- a/spring-web/src/main/java/org/springframework/http/client/AbstractClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/AbstractClientHttpRequest.java @@ -18,6 +18,8 @@ import java.io.IOException; import java.io.OutputStream; +import java.util.LinkedHashMap; +import java.util.Map; import org.springframework.http.HttpHeaders; import org.springframework.lang.Nullable; @@ -39,6 +41,9 @@ public abstract class AbstractClientHttpRequest implements ClientHttpRequest { @Nullable private HttpHeaders readOnlyHeaders; + @Nullable + private Map attributes; + @Override public final HttpHeaders getHeaders() { @@ -60,6 +65,16 @@ public final OutputStream getBody() throws IOException { return getBodyInternal(this.headers); } + @Override + public Map getAttributes() { + Map attributes = this.attributes; + if (attributes == null) { + attributes = new LinkedHashMap<>(); + this.attributes = attributes; + } + return attributes; + } + @Override public final ClientHttpResponse execute() throws IOException { assertNotExecuted(); diff --git a/spring-web/src/main/java/org/springframework/http/client/AbstractStreamingClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/AbstractStreamingClientHttpRequest.java index fd0f4fcccf18..fa3ea1dfea4f 100644 --- a/spring-web/src/main/java/org/springframework/http/client/AbstractStreamingClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/AbstractStreamingClientHttpRequest.java @@ -63,6 +63,7 @@ public final void setBody(Body body) { } @Override + @SuppressWarnings("NullAway") protected final ClientHttpResponse executeInternal(HttpHeaders headers) throws IOException { if (this.body == null && this.bodyStream != null) { this.body = outputStream -> this.bodyStream.writeTo(outputStream); diff --git a/spring-web/src/main/java/org/springframework/http/client/ClientHttpRequestInterceptor.java b/spring-web/src/main/java/org/springframework/http/client/ClientHttpRequestInterceptor.java index c4d36bcf72a3..9c22b283fad9 100644 --- a/spring-web/src/main/java/org/springframework/http/client/ClientHttpRequestInterceptor.java +++ b/spring-web/src/main/java/org/springframework/http/client/ClientHttpRequestInterceptor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,11 +43,9 @@ public interface ClientHttpRequestInterceptor { * wrap} the request to filter HTTP attributes. *

    1. Optionally modify the body of the request.
    2. *
        - *
      • Either - *
      • execute the request using - * {@link ClientHttpRequestExecution#execute(org.springframework.http.HttpRequest, byte[])},
      • - *
      • or
      • - *
      • do not execute the request to block the execution altogether.
      • + *
      • either execute the request using + * {@link ClientHttpRequestExecution#execute(HttpRequest, byte[])}
      • + *
      • or do not execute the request to block the execution altogether
      • *
      *
    3. Optionally wrap the response to filter HTTP attributes.
    4. * diff --git a/spring-web/src/main/java/org/springframework/http/client/ClientHttpResponse.java b/spring-web/src/main/java/org/springframework/http/client/ClientHttpResponse.java index 2dab22cd3e61..b4b90443d4aa 100644 --- a/spring-web/src/main/java/org/springframework/http/client/ClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/ClientHttpResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-web/src/main/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactory.java b/spring-web/src/main/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactory.java index 2b93d88abdd7..6d62a8ffbb34 100644 --- a/spring-web/src/main/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactory.java +++ b/spring-web/src/main/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactory.java @@ -73,6 +73,7 @@ public class HttpComponentsClientHttpRequestFactory implements ClientHttpRequest private long connectionRequestTimeout = -1; + private long readTimeout = -1; /** * Create a new instance of the {@code HttpComponentsClientHttpRequestFactory} @@ -136,7 +137,7 @@ public void setConnectTimeout(int connectTimeout) { * handshakes or CONNECT requests; for that, it is required to * use the {@link SocketConfig} on the * {@link HttpClient} itself. - * @param connectTimeout the timeout value in milliseconds + * @param connectTimeout the timeout as a {@code Duration}. * @since 6.1 * @see RequestConfig#getConnectTimeout() * @see SocketConfig#getSoTimeout @@ -153,7 +154,8 @@ public void setConnectTimeout(Duration connectTimeout) { * A timeout value of 0 specifies an infinite timeout. *

      Additional properties can be configured by specifying a * {@link RequestConfig} instance on a custom {@link HttpClient}. - * @param connectionRequestTimeout the timeout value to request a connection in milliseconds + * @param connectionRequestTimeout the timeout value to request a connection + * in milliseconds * @see RequestConfig#getConnectionRequestTimeout() */ public void setConnectionRequestTimeout(int connectionRequestTimeout) { @@ -167,7 +169,8 @@ public void setConnectionRequestTimeout(int connectionRequestTimeout) { * A timeout value of 0 specifies an infinite timeout. *

      Additional properties can be configured by specifying a * {@link RequestConfig} instance on a custom {@link HttpClient}. - * @param connectionRequestTimeout the timeout value to request a connection in milliseconds + * @param connectionRequestTimeout the timeout value to request a connection + * as a {@code Duration}. * @since 6.1 * @see RequestConfig#getConnectionRequestTimeout() */ @@ -177,6 +180,35 @@ public void setConnectionRequestTimeout(Duration connectionRequestTimeout) { this.connectionRequestTimeout = connectionRequestTimeout.toMillis(); } + /** + * Set the response timeout for the underlying {@link RequestConfig}. + * A timeout value of 0 specifies an infinite timeout. + *

      Additional properties can be configured by specifying a + * {@link RequestConfig} instance on a custom {@link HttpClient}. + * @param readTimeout the timeout value in milliseconds + * @since 6.2 + * @see RequestConfig#getResponseTimeout() + */ + public void setReadTimeout(int readTimeout) { + Assert.isTrue(readTimeout >= 0, "Timeout must be a non-negative value"); + this.readTimeout = readTimeout; + } + + /** + * Set the response timeout for the underlying {@link RequestConfig}. + * A timeout value of 0 specifies an infinite timeout. + *

      Additional properties can be configured by specifying a + * {@link RequestConfig} instance on a custom {@link HttpClient}. + * @param readTimeout the timeout as a {@code Duration}. + * @since 6.2 + * @see RequestConfig#getResponseTimeout() + */ + public void setReadTimeout(Duration readTimeout) { + Assert.notNull(readTimeout, "ReadTimeout must not be null"); + Assert.isTrue(!readTimeout.isNegative(), "Timeout must be a non-negative value"); + this.readTimeout = readTimeout.toMillis(); + } + /** * Indicates whether this request factory should buffer the request body internally. *

      Default is {@code true}. When sending large amounts of data via POST or PUT, it is @@ -267,7 +299,7 @@ protected RequestConfig createRequestConfig(Object client) { */ @SuppressWarnings("deprecation") // setConnectTimeout protected RequestConfig mergeRequestConfig(RequestConfig clientConfig) { - if (this.connectTimeout == -1 && this.connectionRequestTimeout == -1) { // nothing to merge + if (this.connectTimeout == -1 && this.connectionRequestTimeout == -1 && this.readTimeout == -1) { // nothing to merge return clientConfig; } @@ -278,6 +310,9 @@ protected RequestConfig mergeRequestConfig(RequestConfig clientConfig) { if (this.connectionRequestTimeout >= 0) { builder.setConnectionRequestTimeout(this.connectionRequestTimeout, TimeUnit.MILLISECONDS); } + if (this.readTimeout >= 0) { + builder.setResponseTimeout(this.readTimeout, TimeUnit.MILLISECONDS); + } return builder.build(); } diff --git a/spring-web/src/main/java/org/springframework/http/client/InterceptingClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/InterceptingClientHttpRequest.java index 5149618c7b1c..84ee77f3d704 100644 --- a/spring-web/src/main/java/org/springframework/http/client/InterceptingClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/InterceptingClientHttpRequest.java @@ -91,7 +91,12 @@ public ClientHttpResponse execute(HttpRequest request, byte[] body) throws IOExc HttpMethod method = request.getMethod(); ClientHttpRequest delegate = requestFactory.createRequest(request.getURI(), method); request.getHeaders().forEach((key, value) -> delegate.getHeaders().addAll(key, value)); + request.getAttributes().forEach((key, value) -> delegate.getAttributes().put(key, value)); if (body.length > 0) { + long contentLength = delegate.getHeaders().getContentLength(); + if (contentLength > -1 && contentLength != body.length) { + delegate.getHeaders().setContentLength(body.length); + } if (delegate instanceof StreamingHttpOutputMessage streamingOutputMessage) { streamingOutputMessage.setBody(new StreamingHttpOutputMessage.Body() { @Override diff --git a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java index cb813d1628ae..79e0b8323f40 100644 --- a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java @@ -16,6 +16,7 @@ package org.springframework.http.client; +import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; @@ -23,17 +24,19 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; import java.nio.ByteBuffer; import java.time.Duration; import java.util.Collections; import java.util.Locale; import java.util.Set; import java.util.TreeSet; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.concurrent.Flow; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -91,29 +94,35 @@ public URI getURI() { @Override + @SuppressWarnings("NullAway") protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body body) throws IOException { + CompletableFuture> responseFuture = null; try { HttpRequest request = buildRequest(headers, body); - HttpResponse response; + responseFuture = this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); + if (this.timeout != null) { - response = this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()) - .get(this.timeout.toMillis(), TimeUnit.MILLISECONDS); + TimeoutHandler timeoutHandler = new TimeoutHandler(responseFuture, this.timeout); + HttpResponse response = responseFuture.get(); + InputStream inputStream = timeoutHandler.wrapInputStream(response); + return new JdkClientHttpResponse(response, inputStream); } else { - response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + HttpResponse response = responseFuture.get(); + return new JdkClientHttpResponse(response, response.body()); } - return new JdkClientHttpResponse(response); - } - catch (UncheckedIOException ex) { - throw ex.getCause(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); + responseFuture.cancel(true); throw new IOException("Request was interrupted: " + ex.getMessage(), ex); } catch (ExecutionException ex) { Throwable cause = ex.getCause(); + if (cause instanceof CancellationException) { + throw new HttpTimeoutException("Request timed out"); + } if (cause instanceof UncheckedIOException uioEx) { throw uioEx.getCause(); } @@ -127,17 +136,10 @@ else if (cause instanceof IOException ioEx) { throw new IOException(cause.getMessage(), cause); } } - catch (TimeoutException ex) { - throw new IOException("Request timed out: " + ex.getMessage(), ex); - } } - private HttpRequest buildRequest(HttpHeaders headers, @Nullable Body body) { HttpRequest.Builder builder = HttpRequest.newBuilder().uri(this.uri); - if (this.timeout != null) { - builder.timeout(this.timeout); - } headers.forEach((headerName, headerValues) -> { if (!DISALLOWED_HEADERS.contains(headerName.toLowerCase(Locale.ROOT))) { @@ -153,19 +155,18 @@ private HttpRequest buildRequest(HttpHeaders headers, @Nullable Body body) { private HttpRequest.BodyPublisher bodyPublisher(HttpHeaders headers, @Nullable Body body) { if (body != null) { - Flow.Publisher outputStreamPublisher = OutputStreamPublisher.create( - outputStream -> body.writeTo(StreamUtils.nonClosing(outputStream)), - BYTE_MAPPER, this.executor); + Flow.Publisher publisher = new OutputStreamPublisher<>( + os -> body.writeTo(StreamUtils.nonClosing(os)), BYTE_MAPPER, this.executor, null); long contentLength = headers.getContentLength(); if (contentLength > 0) { - return HttpRequest.BodyPublishers.fromPublisher(outputStreamPublisher, contentLength); + return HttpRequest.BodyPublishers.fromPublisher(publisher, contentLength); } else if (contentLength == 0) { return HttpRequest.BodyPublishers.noBody(); } else { - return HttpRequest.BodyPublishers.fromPublisher(outputStreamPublisher); + return HttpRequest.BodyPublishers.fromPublisher(publisher); } } else { @@ -212,4 +213,52 @@ public ByteBuffer map(byte[] b, int off, int len) { } } + + /** + * Temporary workaround to use instead of {@link HttpRequest.Builder#timeout(Duration)} + * until JDK-8258397 + * is fixed. Essentially, create a future wiht a timeout handler, and use it + * to close the response. + * @see OpenJDK discussion thread + */ + private static final class TimeoutHandler { + + private final CompletableFuture timeoutFuture; + + private TimeoutHandler(CompletableFuture> future, Duration timeout) { + + this.timeoutFuture = new CompletableFuture() + .completeOnTimeout(null, timeout.toMillis(), TimeUnit.MILLISECONDS); + + this.timeoutFuture.thenRun(() -> { + if (future.cancel(true) || future.isCompletedExceptionally() || !future.isDone()) { + return; + } + try { + future.get().body().close(); + } + catch (Exception ex) { + // ignore + } + }); + + } + + @Nullable + public InputStream wrapInputStream(HttpResponse response) { + InputStream body = response.body(); + if (body == null) { + return body; + } + return new FilterInputStream(body) { + + @Override + public void close() throws IOException { + TimeoutHandler.this.timeoutFuture.cancel(false); + super.close(); + } + }; + } + } + } diff --git a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpResponse.java b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpResponse.java index fe1895be388e..8f8ce96de891 100644 --- a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpResponse.java @@ -27,6 +27,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; +import org.springframework.lang.Nullable; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedCaseInsensitiveMap; import org.springframework.util.MultiValueMap; @@ -48,11 +49,10 @@ class JdkClientHttpResponse implements ClientHttpResponse { private final InputStream body; - public JdkClientHttpResponse(HttpResponse response) { + public JdkClientHttpResponse(HttpResponse response, @Nullable InputStream body) { this.response = response; this.headers = adaptHeaders(response); - InputStream inputStream = response.body(); - this.body = (inputStream != null ? inputStream : InputStream.nullInputStream()); + this.body = (body != null ? body : InputStream.nullInputStream()); } private static HttpHeaders adaptHeaders(HttpResponse response) { diff --git a/spring-web/src/main/java/org/springframework/http/client/JettyClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/JettyClientHttpRequest.java index c8462dda81dc..c846684e4101 100644 --- a/spring-web/src/main/java/org/springframework/http/client/JettyClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/JettyClientHttpRequest.java @@ -69,6 +69,7 @@ public URI getURI() { } @Override + @SuppressWarnings("NullAway") protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body body) throws IOException { if (!headers.isEmpty()) { this.request.headers(httpFields -> { diff --git a/spring-web/src/main/java/org/springframework/http/client/JettyClientHttpRequestFactory.java b/spring-web/src/main/java/org/springframework/http/client/JettyClientHttpRequestFactory.java index b531d8b4dcad..ad8e383b86fa 100644 --- a/spring-web/src/main/java/org/springframework/http/client/JettyClientHttpRequestFactory.java +++ b/spring-web/src/main/java/org/springframework/http/client/JettyClientHttpRequestFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.URI; import java.time.Duration; +import java.util.concurrent.TimeUnit; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.Request; @@ -133,6 +134,7 @@ public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IO } Request request = this.httpClient.newRequest(uri).method(httpMethod.name()); + request.timeout(this.readTimeout, TimeUnit.MILLISECONDS); return new JettyClientHttpRequest(request, this.readTimeout); } } diff --git a/spring-web/src/main/java/org/springframework/http/client/MultipartBodyBuilder.java b/spring-web/src/main/java/org/springframework/http/client/MultipartBodyBuilder.java index d9372552c321..46640fed4144 100644 --- a/spring-web/src/main/java/org/springframework/http/client/MultipartBodyBuilder.java +++ b/spring-web/src/main/java/org/springframework/http/client/MultipartBodyBuilder.java @@ -63,7 +63,7 @@ * Resource image = new ClassPathResource("image.jpg"); * builder.part("image", image).header("foo", "bar"); * - * // Add content (e.g. JSON) + * // Add content (for example, JSON) * Account account = ... * builder.part("account", account).header("foo", "bar"); * @@ -104,7 +104,7 @@ public MultipartBodyBuilder() { *

        *
      • String -- form field *
      • {@link org.springframework.core.io.Resource Resource} -- file part - *
      • Object -- content to be encoded (e.g. to JSON) + *
      • Object -- content to be encoded (for example, to JSON). *
      • {@link HttpEntity} -- part content and headers although generally it's * easier to add headers through the returned builder *
      • {@link Part} -- a part from a server request diff --git a/spring-web/src/main/java/org/springframework/http/client/OkHttp3ClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/OkHttp3ClientHttpRequest.java index 20bc2ed76214..f6448d92448e 100644 --- a/spring-web/src/main/java/org/springframework/http/client/OkHttp3ClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/OkHttp3ClientHttpRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 @@ * @author Arjen Poutsma * @author Roy Clarkson * @since 4.3 + * @deprecated since 6.1, in favor of other HTTP client libraries; + * scheduled for removal in 7.0 */ @Deprecated(since = "6.1", forRemoval = true) class OkHttp3ClientHttpRequest extends AbstractStreamingClientHttpRequest { diff --git a/spring-web/src/main/java/org/springframework/http/client/OkHttp3ClientHttpRequestFactory.java b/spring-web/src/main/java/org/springframework/http/client/OkHttp3ClientHttpRequestFactory.java index 88d1ee186e4f..6a9a637e339c 100644 --- a/spring-web/src/main/java/org/springframework/http/client/OkHttp3ClientHttpRequestFactory.java +++ b/spring-web/src/main/java/org/springframework/http/client/OkHttp3ClientHttpRequestFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 +36,8 @@ * @author Arjen Poutsma * @author Roy Clarkson * @since 4.3 - * @deprecated since 6.1, in favor of other {@link ClientHttpRequestFactory} - * implementations; scheduled for removal in 6.2 + * @deprecated since 6.1, in favor of other {@link ClientHttpRequestFactory} implementations; + * scheduled for removal in 7.0 */ @Deprecated(since = "6.1", forRemoval = true) public class OkHttp3ClientHttpRequestFactory implements ClientHttpRequestFactory, DisposableBean { diff --git a/spring-web/src/main/java/org/springframework/http/client/OkHttp3ClientHttpResponse.java b/spring-web/src/main/java/org/springframework/http/client/OkHttp3ClientHttpResponse.java index a2f4da45086c..1e233e82ad52 100644 --- a/spring-web/src/main/java/org/springframework/http/client/OkHttp3ClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/OkHttp3ClientHttpResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 @@ * @author Arjen Poutsma * @author Roy Clarkson * @since 4.3 + * @deprecated since 6.1, in favor of other HTTP client libraries; + * scheduled for removal in 7.0 */ @Deprecated(since = "6.1", forRemoval = true) class OkHttp3ClientHttpResponse implements ClientHttpResponse { diff --git a/spring-web/src/main/java/org/springframework/http/client/OutputStreamPublisher.java b/spring-web/src/main/java/org/springframework/http/client/OutputStreamPublisher.java index 67b33cd8fc69..8354172f68c9 100644 --- a/spring-web/src/main/java/org/springframework/http/client/OutputStreamPublisher.java +++ b/spring-web/src/main/java/org/springframework/http/client/OutputStreamPublisher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 +30,12 @@ import org.springframework.util.Assert; /** - * Bridges between {@link OutputStream} and - * {@link Flow.Publisher Flow.Publisher<T>}. + * Bridges between {@link OutputStream} and {@link Flow.Publisher Flow.Publisher<T>}. + * + *

        When there is demand on the Reactive Streams subscription, any write to + * the OutputStream is mapped to a buffer and published to the subscriber. + * If there is no demand, writes block until demand materializes. + * If the subscription is cancelled, further writes raise {@code IOException}. * *

        Note that this class has a near duplicate in * {@link org.springframework.core.io.buffer.OutputStreamPublisher}. @@ -39,8 +43,7 @@ * @author Oleh Dokuka * @author Arjen Poutsma * @since 6.1 - * @param the published item type - * @see #create(OutputStreamHandler, ByteMapper, Executor) + * @param the published byte buffer type */ final class OutputStreamPublisher implements Flow.Publisher { @@ -56,157 +59,62 @@ final class OutputStreamPublisher implements Flow.Publisher { private final int chunkSize; - private OutputStreamPublisher(OutputStreamHandler outputStreamHandler, ByteMapper byteMapper, Executor executor, int chunkSize) { - this.outputStreamHandler = outputStreamHandler; - this.byteMapper = byteMapper; - this.executor = executor; - this.chunkSize = chunkSize; - } - - - /** - * Creates a new {@code Publisher} based on bytes written to a - * {@code OutputStream}. The parameter {@code byteMapper} is used to map - * from written bytes to the published type. - *

          - *
        • The parameter {@code outputStreamHandler} is invoked once per - * subscription of the returned {@code Publisher}, when the first - * item is - * {@linkplain Flow.Subscription#request(long) requested}.
        • - *
        • {@link OutputStream#write(byte[], int, int) OutputStream.write()} - * invocations made by {@code outputStreamHandler} are buffered until they - * exceed the default chunk size of 1024, and then result in a - * {@linkplain Flow.Subscriber#onNext(Object) published} item - * if there is {@linkplain Flow.Subscription#request(long) demand}.
        • - *
        • If there is no demand, {@code OutputStream.write()} will block - * until there is.
        • - *
        • If the subscription is {@linkplain Flow.Subscription#cancel() cancelled}, - * {@code OutputStream.write()} will throw a {@code IOException}.
        • - *
        • The subscription is - * {@linkplain Flow.Subscriber#onComplete() completed} when - * {@code outputStreamHandler} completes.
        • - *
        • Any {@code IOException}s thrown from {@code outputStreamHandler} will - * be dispatched to the {@linkplain Flow.Subscriber#onError(Throwable) Subscriber}. - *
        - * @param outputStreamHandler invoked when the first buffer is requested - * @param byteMapper maps written bytes to {@code T} - * @param executor used to invoke the {@code outputStreamHandler} - * @param the publisher type - * @return a {@code Publisher} based on bytes written by - * {@code outputStreamHandler} mapped by {@code byteMapper} - */ - public static Flow.Publisher create(OutputStreamHandler outputStreamHandler, ByteMapper byteMapper, - Executor executor) { - - Assert.notNull(outputStreamHandler, "OutputStreamHandler must not be null"); - Assert.notNull(byteMapper, "ByteMapper must not be null"); - Assert.notNull(executor, "Executor must not be null"); - - return new OutputStreamPublisher<>(outputStreamHandler, byteMapper, executor, DEFAULT_CHUNK_SIZE); - } - /** - * Creates a new {@code Publisher} based on bytes written to a - * {@code OutputStream}. The parameter {@code byteMapper} is used to map - * from written bytes to the published type. - *
          - *
        • The parameter {@code outputStreamHandler} is invoked once per - * subscription of the returned {@code Publisher}, when the first - * item is - * {@linkplain Flow.Subscription#request(long) requested}.
        • - *
        • {@link OutputStream#write(byte[], int, int) OutputStream.write()} - * invocations made by {@code outputStreamHandler} are buffered until they - * exceed {@code chunkSize}, and then result in a - * {@linkplain Flow.Subscriber#onNext(Object) published} item - * if there is {@linkplain Flow.Subscription#request(long) demand}.
        • - *
        • If there is no demand, {@code OutputStream.write()} will block - * until there is.
        • - *
        • If the subscription is {@linkplain Flow.Subscription#cancel() cancelled}, - * {@code OutputStream.write()} will throw a {@code IOException}.
        • - *
        • The subscription is - * {@linkplain Flow.Subscriber#onComplete() completed} when - * {@code outputStreamHandler} completes.
        • - *
        • Any {@code IOException}s thrown from {@code outputStreamHandler} will - * be dispatched to the {@linkplain Flow.Subscriber#onError(Throwable) Subscriber}. - *
        + * Create an instance. * @param outputStreamHandler invoked when the first buffer is requested * @param byteMapper maps written bytes to {@code T} * @param executor used to invoke the {@code outputStreamHandler} - * @param the publisher type - * @return a {@code Publisher} based on bytes written by - * {@code outputStreamHandler} mapped by {@code byteMapper} + * @param chunkSize the chunk sizes to be produced by the publisher */ - public static Flow.Publisher create(OutputStreamHandler outputStreamHandler, ByteMapper byteMapper, - Executor executor, int chunkSize) { + OutputStreamPublisher( + OutputStreamHandler outputStreamHandler, ByteMapper byteMapper, + Executor executor, @Nullable Integer chunkSize) { Assert.notNull(outputStreamHandler, "OutputStreamHandler must not be null"); Assert.notNull(byteMapper, "ByteMapper must not be null"); Assert.notNull(executor, "Executor must not be null"); - Assert.isTrue(chunkSize > 0, "ChunkSize must be larger than 0"); + Assert.isTrue(chunkSize == null || chunkSize > 0, "ChunkSize must be larger than 0"); - return new OutputStreamPublisher<>(outputStreamHandler, byteMapper, executor, chunkSize); + this.outputStreamHandler = outputStreamHandler; + this.byteMapper = byteMapper; + this.executor = executor; + this.chunkSize = (chunkSize != null ? chunkSize : DEFAULT_CHUNK_SIZE); } @Override public void subscribe(Flow.Subscriber subscriber) { + // We don't use Assert.notNull(), because a NullPointerException is required + // for Reactive Streams compliance. Objects.requireNonNull(subscriber, "Subscriber must not be null"); - OutputStreamSubscription subscription = new OutputStreamSubscription<>(subscriber, this.outputStreamHandler, - this.byteMapper, this.chunkSize); + OutputStreamSubscription subscription = new OutputStreamSubscription<>( + subscriber, this.outputStreamHandler, this.byteMapper, this.chunkSize); + subscriber.onSubscribe(subscription); this.executor.execute(subscription::invokeHandler); } /** - * Defines the contract for handling the {@code OutputStream} provided by - * the {@code OutputStreamPublisher}. + * Contract to provide callback access to the {@link OutputStream}. */ @FunctionalInterface public interface OutputStreamHandler { - /** - * Use the given stream for writing. - *
          - *
        • If the linked subscription has - * {@linkplain Flow.Subscription#request(long) demand}, any - * {@linkplain OutputStream#write(byte[], int, int) written} bytes - * will be {@linkplain ByteMapper#map(byte[], int, int) mapped} - * and {@linkplain Flow.Subscriber#onNext(Object) published} to the - * {@link Flow.Subscriber Subscriber}.
        • - *
        • If there is no demand, any - * {@link OutputStream#write(byte[], int, int) write()} invocations will - * block until there is demand.
        • - *
        • If the linked subscription is - * {@linkplain Flow.Subscription#cancel() cancelled}, - * {@link OutputStream#write(byte[], int, int) write()} invocations will - * result in a {@code IOException}.
        • - *
        - * @param outputStream the stream to write to - * @throws IOException any thrown I/O errors will be dispatched to the - * {@linkplain Flow.Subscriber#onError(Throwable) Subscriber} - */ - void handle(OutputStream outputStream) throws IOException; + void handle(OutputStream outputStream) throws Exception; } /** - * Maps bytes written to in {@link OutputStreamHandler#handle(OutputStream)} - * to published items. - * @param the type to map to + * Maps from bytes to byte buffers. + * @param the type of byte buffer to map to */ public interface ByteMapper { - /** - * Maps a single byte to {@code T}. - */ T map(int b); - /** - * Maps a byte array to {@code T}. - */ T map(byte[] b, int off, int len); } @@ -214,8 +122,7 @@ public interface ByteMapper { private static final class OutputStreamSubscription extends OutputStream implements Flow.Subscription { - static final Object READY = new Object(); - + private static final Object READY = new Object(); private final Flow.Subscriber actual; @@ -234,23 +141,21 @@ private static final class OutputStreamSubscription extends OutputStream impl private long produced; - - public OutputStreamSubscription(Flow.Subscriber actual, OutputStreamHandler outputStreamHandler, + OutputStreamSubscription( + Flow.Subscriber actual, OutputStreamHandler outputStreamHandler, ByteMapper byteMapper, int chunkSize) { + this.actual = actual; - this.byteMapper = byteMapper; this.outputStreamHandler = outputStreamHandler; + this.byteMapper = byteMapper; this.chunkSize = chunkSize; } @Override public void write(int b) throws IOException { checkDemandAndAwaitIfNeeded(); - T next = this.byteMapper.map(b); - this.actual.onNext(next); - this.produced++; } @@ -262,11 +167,8 @@ public void write(byte[] b) throws IOException { @Override public void write(byte[] b, int off, int len) throws IOException { checkDemandAndAwaitIfNeeded(); - T next = this.byteMapper.map(b, off, len); - this.actual.onNext(next); - this.produced++; } @@ -308,18 +210,19 @@ private void invokeHandler() { try (OutputStream outputStream = new BufferedOutputStream(this, this.chunkSize)) { this.outputStreamHandler.handle(outputStream); } - catch (IOException ex) { + catch (Exception ex) { long previousState = tryTerminate(); if (isCancelled(previousState)) { return; } - if (isTerminated(previousState)) { // failure due to illegal requestN - this.actual.onError(this.error); - return; + Throwable error = this.error; + if (error != null) { + this.actual.onError(error); + return; + } } - this.actual.onError(ex); return; } @@ -328,13 +231,14 @@ private void invokeHandler() { if (isCancelled(previousState)) { return; } - if (isTerminated(previousState)) { // failure due to illegal requestN - this.actual.onError(this.error); - return; + Throwable error = this.error; + if (error != null) { + this.actual.onError(error); + return; + } } - this.actual.onComplete(); } @@ -344,16 +248,13 @@ public void request(long n) { if (n <= 0) { this.error = new IllegalArgumentException("request should be a positive number"); long previousState = tryTerminate(); - if (isTerminated(previousState) || isCancelled(previousState)) { return; } - if (previousState > 0) { // error should eventually be observed and propagated return; } - // resume parked thread, so it can observe error and propagate it resume(); return; @@ -411,11 +312,9 @@ private void resume() { private long tryCancel() { while (true) { long r = this.requested.get(); - if (isCancelled(r)) { return r; } - if (this.requested.compareAndSet(r, Long.MIN_VALUE)) { return r; } @@ -425,11 +324,9 @@ private long tryCancel() { private long tryTerminate() { while (true) { long r = this.requested.get(); - if (isCancelled(r) || isTerminated(r)) { return r; } - if (this.requested.compareAndSet(r, Long.MIN_VALUE | Long.MAX_VALUE)) { return r; } @@ -484,4 +381,5 @@ private static long addCap(long a, long b) { return res; } } + } diff --git a/spring-web/src/main/java/org/springframework/http/client/ReactorClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/ReactorClientHttpRequest.java new file mode 100644 index 000000000000..feddda57a763 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/client/ReactorClientHttpRequest.java @@ -0,0 +1,200 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.client; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicReference; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import org.reactivestreams.FlowAdapters; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; +import reactor.netty.NettyOutbound; +import reactor.netty.http.client.HttpClient; +import reactor.netty.http.client.HttpClientRequest; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.lang.Nullable; +import org.springframework.util.StreamUtils; + +/** + * {@link ClientHttpRequest} implementation for the Reactor-Netty HTTP client. + * Created via the {@link ReactorClientHttpRequestFactory}. + * + * @author Arjen Poutsma + * @author Juergen Hoeller + * @since 6.1 + */ +final class ReactorClientHttpRequest extends AbstractStreamingClientHttpRequest { + + private final HttpClient httpClient; + + private final HttpMethod method; + + private final URI uri; + + @Nullable + private final Duration exchangeTimeout; + + + /** + * Create an instance. + * @param httpClient the client to perform the request with + * @param method the HTTP method + * @param uri the URI for the request + * @since 6.2 + */ + public ReactorClientHttpRequest(HttpClient httpClient, HttpMethod method, URI uri) { + this.httpClient = httpClient; + this.method = method; + this.uri = uri; + this.exchangeTimeout = null; + } + + /** + * Package private constructor for use until exchangeTimeout is removed. + */ + ReactorClientHttpRequest(HttpClient httpClient, HttpMethod method, URI uri, @Nullable Duration exchangeTimeout) { + this.httpClient = httpClient; + this.method = method; + this.uri = uri; + this.exchangeTimeout = exchangeTimeout; + } + + /** + * Original constructor with timeout values. + * @deprecated without a replacement; readTimeout is now applied to the + * underlying client via {@link HttpClient#responseTimeout(Duration)}, and the + * value passed here is not used; exchangeTimeout is deprecated and superseded + * by Reactor Netty timeout configuration, but applied if set. + */ + @Deprecated(since = "6.2", forRemoval = true) + public ReactorClientHttpRequest( + HttpClient httpClient, URI uri, HttpMethod method, + @Nullable Duration exchangeTimeout, @Nullable Duration readTimeout) { + + this.httpClient = httpClient; + this.method = method; + this.uri = uri; + this.exchangeTimeout = exchangeTimeout; + } + + + @Override + public HttpMethod getMethod() { + return this.method; + } + + @Override + public URI getURI() { + return this.uri; + } + + + @Override + protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body body) throws IOException { + + HttpClient.RequestSender sender = this.httpClient + .request(io.netty.handler.codec.http.HttpMethod.valueOf(this.method.name())); + + sender = (this.uri.isAbsolute() ? sender.uri(this.uri) : sender.uri(this.uri.toString())); + + try { + Mono mono = + sender.send((request, outbound) -> send(headers, body, request, outbound)) + .responseConnection((response, conn) -> Mono.just(new ReactorClientHttpResponse(response, conn))) + .next(); + + ReactorClientHttpResponse clientResponse = + (this.exchangeTimeout != null ? mono.block(this.exchangeTimeout) : mono.block()); + + if (clientResponse == null) { + throw new IOException("HTTP exchange resulted in no result"); + } + + return clientResponse; + } + catch (RuntimeException ex) { + throw convertException(ex); + } + } + + private Publisher send( + HttpHeaders headers, @Nullable Body body, HttpClientRequest request, NettyOutbound outbound) { + + headers.forEach((key, value) -> request.requestHeaders().set(key, value)); + + if (body == null) { + // NettyOutbound#subscribe calls then() and that expects a body + // Use empty Mono instead for a more optimal send + return Mono.empty(); + } + + AtomicReference executorRef = new AtomicReference<>(); + + return outbound + .withConnection(connection -> executorRef.set(connection.channel().eventLoop())) + .send(FlowAdapters.toPublisher(new OutputStreamPublisher<>( + os -> body.writeTo(StreamUtils.nonClosing(os)), new ByteBufMapper(outbound), + executorRef.getAndSet(null), null))); + } + + static IOException convertException(RuntimeException ex) { + Throwable cause = ex.getCause(); // Exceptions.ReactiveException is private + if (cause instanceof IOException ioEx) { + return ioEx; + } + if (cause instanceof UncheckedIOException uioEx) { + IOException ioEx = uioEx.getCause(); + if (ioEx != null) { + return ioEx; + } + } + return new IOException(ex.getMessage(), (cause != null ? cause : ex)); + } + + + private static final class ByteBufMapper implements OutputStreamPublisher.ByteMapper { + + private final ByteBufAllocator allocator; + + public ByteBufMapper(NettyOutbound outbound) { + this.allocator = outbound.alloc(); + } + + @Override + public ByteBuf map(int b) { + ByteBuf buf = this.allocator.buffer(1); + buf.writeByte(b); + return buf; + } + + @Override + public ByteBuf map(byte[] b, int off, int len) { + ByteBuf buf = this.allocator.buffer(len); + buf.writeBytes(b, off, len); + return buf; + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/client/ReactorClientHttpRequestFactory.java b/spring-web/src/main/java/org/springframework/http/client/ReactorClientHttpRequestFactory.java new file mode 100644 index 000000000000..f3366497cc3e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/client/ReactorClientHttpRequestFactory.java @@ -0,0 +1,260 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.client; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.function.Function; + +import io.netty.channel.ChannelOption; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.netty.http.client.HttpClient; +import reactor.netty.resources.ConnectionProvider; +import reactor.netty.resources.LoopResources; + +import org.springframework.context.SmartLifecycle; +import org.springframework.http.HttpMethod; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Reactor-Netty implementation of {@link ClientHttpRequestFactory}. + * + *

        This class implements {@link SmartLifecycle} and can be optionally declared + * as a Spring-managed bean in order to support JVM Checkpoint Restore. + * + * @author Arjen Poutsma + * @author Juergen Hoeller + * @author Sebastien Deleuze + * @since 6.2 + */ +public class ReactorClientHttpRequestFactory implements ClientHttpRequestFactory, SmartLifecycle { + + private static final Log logger = LogFactory.getLog(ReactorClientHttpRequestFactory.class); + + private static final Function defaultInitializer = + client -> client.compress(true).responseTimeout(Duration.ofSeconds(10)); + + + @Nullable + private final ReactorResourceFactory resourceFactory; + + @Nullable + private final Function mapper; + + @Nullable + private Integer connectTimeout; + + @Nullable + private Duration readTimeout; + + @Nullable + private Duration exchangeTimeout; + + @Nullable + private volatile HttpClient httpClient; + + private final Object lifecycleMonitor = new Object(); + + + /** + * Constructor with default client, created via {@link HttpClient#create()}, + * and with {@link HttpClient#compress compression} enabled. + */ + public ReactorClientHttpRequestFactory() { + this(defaultInitializer.apply(HttpClient.create())); + } + + /** + * Constructor with a given {@link HttpClient} instance. + * @param client the client to use + */ + public ReactorClientHttpRequestFactory(HttpClient client) { + Assert.notNull(client, "HttpClient must not be null"); + this.resourceFactory = null; + this.mapper = null; + this.httpClient = client; + } + + /** + * Constructor with externally managed Reactor Netty resources, including + * {@link LoopResources} for event loop threads, and {@link ConnectionProvider} + * for connection pooling. + *

        Generally, it is recommended to share resources for event loop + * concurrency. This can be achieved either by participating in the JVM-wide, + * global resources held in {@link reactor.netty.http.HttpResources}, or by + * using a specific, shared set of resources through a + * {@link ReactorResourceFactory} bean. The latter can ensure that resources + * are shut down when the Spring ApplicationContext is stopped/closed and + * restarted again (e.g. JVM checkpoint restore). + * @param resourceFactory the resource factory to get resources from + * @param mapper for further initialization of the client + */ + public ReactorClientHttpRequestFactory( + ReactorResourceFactory resourceFactory, Function mapper) { + + this.resourceFactory = resourceFactory; + this.mapper = mapper; + if (resourceFactory.isRunning()) { + this.httpClient = createHttpClient(resourceFactory, mapper); + } + } + + private HttpClient createHttpClient(ReactorResourceFactory factory, Function mapper) { + HttpClient client = HttpClient.create(factory.getConnectionProvider()); + client = defaultInitializer.andThen(mapper).apply(client); + client = client.runOn(factory.getLoopResources()); + if (this.connectTimeout != null) { + client = client.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, this.connectTimeout); + } + if (this.readTimeout != null) { + client = client.responseTimeout(this.readTimeout); + } + return client; + } + + + /** + * Set the connect timeout value on the underlying client. + * Effectively, a shortcut for + * {@code httpClient.option(CONNECT_TIMEOUT_MILLIS, timeout)}. + *

        By default, set to 30 seconds. + * @param connectTimeout the timeout value in millis; use 0 to never time out. + * @see HttpClient#option(ChannelOption, Object) + * @see ChannelOption#CONNECT_TIMEOUT_MILLIS + * @see Connection Timeout + */ + public void setConnectTimeout(int connectTimeout) { + Assert.isTrue(connectTimeout >= 0, "Timeout must be a non-negative value"); + this.connectTimeout = connectTimeout; + HttpClient httpClient = this.httpClient; + if (httpClient != null) { + this.httpClient = httpClient.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, this.connectTimeout); + } + } + + /** + * Variant of {@link #setConnectTimeout(int)} with a {@link Duration} value. + */ + public void setConnectTimeout(Duration connectTimeout) { + Assert.notNull(connectTimeout, "ConnectTimeout must not be null"); + setConnectTimeout((int) connectTimeout.toMillis()); + } + + /** + * Set the read timeout value on the underlying client. + * Effectively, a shortcut for {@link HttpClient#responseTimeout(Duration)}. + *

        By default, set to 10 seconds. + * @param timeout the read timeout value in millis; must be > 0. + */ + public void setReadTimeout(Duration timeout) { + Assert.notNull(timeout, "ReadTimeout must not be null"); + Assert.isTrue(timeout.toMillis() > 0, "Timeout must be a positive value"); + this.readTimeout = timeout; + HttpClient httpClient = this.httpClient; + if (httpClient != null) { + this.httpClient = httpClient.responseTimeout(timeout); + } + } + + /** + * Variant of {@link #setReadTimeout(Duration)} with a long value. + */ + public void setReadTimeout(long readTimeout) { + setReadTimeout(Duration.ofMillis(readTimeout)); + } + + /** + * Set the timeout for the HTTP exchange in milliseconds. + *

        By default, as of 6.2 this is no longer set. + * @see #setConnectTimeout(int) + * @see #setReadTimeout(Duration) + * @see Timeout Configuration + * @deprecated as of 6.2 and no longer set by default (previously 5 seconds) + * in favor of using Reactor Netty HttpClient timeout configuration. + */ + @Deprecated(since = "6.2", forRemoval = true) + public void setExchangeTimeout(long exchangeTimeout) { + Assert.isTrue(exchangeTimeout > 0, "Timeout must be a positive value"); + this.exchangeTimeout = Duration.ofMillis(exchangeTimeout); + } + + /** + * Variant of {@link #setExchangeTimeout(long)} with a Duration value. + *

        By default, as of 6.2 this is no longer set. + * @see #setConnectTimeout(int) + * @see #setReadTimeout(Duration) + * @see Timeout Configuration + * @deprecated as of 6.2 and no longer set by default (previously 5 seconds) + * in favor of using Reactor Netty HttpClient timeout configuration. + */ + @Deprecated(since = "6.2", forRemoval = true) + public void setExchangeTimeout(Duration exchangeTimeout) { + Assert.notNull(exchangeTimeout, "ExchangeTimeout must not be null"); + setExchangeTimeout((int) exchangeTimeout.toMillis()); + } + + + @Override + public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException { + HttpClient client = this.httpClient; + if (client == null) { + Assert.state(this.resourceFactory != null && this.mapper != null, + "Expected HttpClient or ResourceFactory and mapper"); + client = createHttpClient(this.resourceFactory, this.mapper); + } + return new ReactorClientHttpRequest(client, httpMethod, uri, this.exchangeTimeout); + } + + + @Override + public void start() { + if (this.resourceFactory != null && this.mapper != null) { + synchronized (this.lifecycleMonitor) { + if (this.httpClient == null) { + this.httpClient = createHttpClient(this.resourceFactory, this.mapper); + } + } + } + else { + logger.warn("Restarting a ReactorClientHttpRequestFactory bean is only supported " + + "with externally managed Reactor Netty resources"); + } + } + + @Override + public void stop() { + if (this.resourceFactory != null && this.mapper != null) { + synchronized (this.lifecycleMonitor) { + this.httpClient = null; + } + } + } + + @Override + public boolean isRunning() { + return (this.httpClient != null); + } + + @Override + public int getPhase() { + return 1; // start after ReactorResourceFactory (0) + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/client/ReactorNettyClientResponse.java b/spring-web/src/main/java/org/springframework/http/client/ReactorClientHttpResponse.java similarity index 59% rename from spring-web/src/main/java/org/springframework/http/client/ReactorNettyClientResponse.java rename to spring-web/src/main/java/org/springframework/http/client/ReactorClientHttpResponse.java index 9d1b18a25252..b83395a163b1 100644 --- a/spring-web/src/main/java/org/springframework/http/client/ReactorNettyClientResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/ReactorClientHttpResponse.java @@ -20,7 +20,10 @@ import java.io.InputStream; import java.time.Duration; +import io.netty.buffer.ByteBuf; +import org.reactivestreams.FlowAdapters; import reactor.netty.Connection; +import reactor.netty.http.client.HttpClient; import reactor.netty.http.client.HttpClientResponse; import org.springframework.http.HttpHeaders; @@ -36,7 +39,7 @@ * @author Juergen Hoeller * @since 6.1 */ -final class ReactorNettyClientResponse implements ClientHttpResponse { +final class ReactorClientHttpResponse implements ClientHttpResponse { private final HttpClientResponse response; @@ -44,16 +47,35 @@ final class ReactorNettyClientResponse implements ClientHttpResponse { private final HttpHeaders headers; - private final Duration readTimeout; - @Nullable private volatile InputStream body; - public ReactorNettyClientResponse(HttpClientResponse response, Connection connection, Duration readTimeout) { + /** + * Create a response instance. + * @param response the Reactor Netty response + * @param connection the connection for the exchange + * @since 6.2 + */ + public ReactorClientHttpResponse(HttpClientResponse response, Connection connection) { + this.response = response; + this.connection = connection; + this.headers = HttpHeaders.readOnlyHttpHeaders( + new Netty4HeadersAdapter(response.responseHeaders())); + } + + /** + * Original constructor. + * @deprecated without a replacement; readTimeout is now applied to the + * underlying client via {@link HttpClient#responseTimeout(Duration)}, and the + * value passed here is not used. + */ + @Deprecated(since = "6.2", forRemoval = true) + public ReactorClientHttpResponse( + HttpClientResponse response, Connection connection, @Nullable Duration readTimeout) { + this.response = response; this.connection = connection; - this.readTimeout = readTimeout; this.headers = HttpHeaders.readOnlyHttpHeaders(new Netty4HeadersAdapter(response.responseHeaders())); } @@ -79,19 +101,22 @@ public InputStream getBody() throws IOException { if (body != null) { return body; } - try { - body = this.connection.inbound().receive().aggregate().asInputStream().block(this.readTimeout); + SubscriberInputStream is = new SubscriberInputStream<>( + byteBuf -> { + byte[] bytes = new byte[byteBuf.readableBytes()]; + byteBuf.readBytes(bytes); + byteBuf.release(); + return bytes; + }, + ByteBuf::release, 16); + this.connection.inbound().receive().retain().subscribe(FlowAdapters.toSubscriber(is)); + this.body = is; + return is; } catch (RuntimeException ex) { - throw ReactorNettyClientRequest.convertException(ex); - } - - if (body == null) { - body = InputStream.nullInputStream(); + throw ReactorClientHttpRequest.convertException(ex); } - this.body = body; - return body; } @Override @@ -101,7 +126,8 @@ public void close() { StreamUtils.drain(body); body.close(); } - catch (IOException ignored) { + catch (IOException ex) { + // ignore } } diff --git a/spring-web/src/main/java/org/springframework/http/client/ReactorNettyClientRequest.java b/spring-web/src/main/java/org/springframework/http/client/ReactorNettyClientRequest.java deleted file mode 100644 index 5f964dbb1208..000000000000 --- a/spring-web/src/main/java/org/springframework/http/client/ReactorNettyClientRequest.java +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed under the Apache License, Version 2.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.http.client; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.net.URI; -import java.time.Duration; -import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicReference; - -import io.netty.buffer.ByteBuf; -import io.netty.buffer.ByteBufAllocator; -import org.reactivestreams.FlowAdapters; -import org.reactivestreams.Publisher; -import reactor.core.publisher.Mono; -import reactor.netty.NettyOutbound; -import reactor.netty.http.client.HttpClient; -import reactor.netty.http.client.HttpClientRequest; - -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.lang.Nullable; -import org.springframework.util.StreamUtils; - -/** - * {@link ClientHttpRequest} implementation for the Reactor-Netty HTTP client. - * Created via the {@link ReactorNettyClientRequestFactory}. - * - * @author Arjen Poutsma - * @author Juergen Hoeller - * @since 6.1 - */ -final class ReactorNettyClientRequest extends AbstractStreamingClientHttpRequest { - - private final HttpClient httpClient; - - private final HttpMethod method; - - private final URI uri; - - private final Duration exchangeTimeout; - - private final Duration readTimeout; - - - public ReactorNettyClientRequest(HttpClient httpClient, URI uri, HttpMethod method, - Duration exchangeTimeout, Duration readTimeout) { - - this.httpClient = httpClient; - this.method = method; - this.uri = uri; - this.exchangeTimeout = exchangeTimeout; - this.readTimeout = readTimeout; - } - - - @Override - public HttpMethod getMethod() { - return this.method; - } - - @Override - public URI getURI() { - return this.uri; - } - - - @Override - protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body body) throws IOException { - HttpClient.RequestSender requestSender = this.httpClient - .request(io.netty.handler.codec.http.HttpMethod.valueOf(this.method.name())); - - requestSender = (this.uri.isAbsolute() ? requestSender.uri(this.uri) : requestSender.uri(this.uri.toString())); - - try { - ReactorNettyClientResponse result = requestSender.send((reactorRequest, nettyOutbound) -> - send(headers, body, reactorRequest, nettyOutbound)) - .responseConnection((reactorResponse, connection) -> - Mono.just(new ReactorNettyClientResponse(reactorResponse, connection, this.readTimeout))) - .next() - .block(this.exchangeTimeout); - - if (result == null) { - throw new IOException("HTTP exchange resulted in no result"); - } - else { - return result; - } - } - catch (RuntimeException ex) { - throw convertException(ex); - } - } - - private Publisher send(HttpHeaders headers, @Nullable Body body, - HttpClientRequest reactorRequest, NettyOutbound nettyOutbound) { - - headers.forEach((key, value) -> reactorRequest.requestHeaders().set(key, value)); - - if (body != null) { - AtomicReference executor = new AtomicReference<>(); - - return nettyOutbound - .withConnection(connection -> executor.set(connection.channel().eventLoop())) - .send(FlowAdapters.toPublisher(OutputStreamPublisher.create( - outputStream -> body.writeTo(StreamUtils.nonClosing(outputStream)), - new ByteBufMapper(nettyOutbound.alloc()), - executor.getAndSet(null)))); - } - else { - return nettyOutbound; - } - } - - static IOException convertException(RuntimeException ex) { - // Exceptions.ReactiveException is package private - Throwable cause = ex.getCause(); - - if (cause instanceof IOException ioEx) { - return ioEx; - } - if (cause instanceof UncheckedIOException uioEx) { - IOException ioEx = uioEx.getCause(); - if (ioEx != null) { - return ioEx; - } - } - return new IOException(ex.getMessage(), (cause != null ? cause : ex)); - } - - - private static final class ByteBufMapper implements OutputStreamPublisher.ByteMapper { - - private final ByteBufAllocator allocator; - - public ByteBufMapper(ByteBufAllocator allocator) { - this.allocator = allocator; - } - - @Override - public ByteBuf map(int b) { - ByteBuf byteBuf = this.allocator.buffer(1); - byteBuf.writeByte(b); - return byteBuf; - } - - @Override - public ByteBuf map(byte[] b, int off, int len) { - ByteBuf byteBuf = this.allocator.buffer(len); - byteBuf.writeBytes(b, off, len); - return byteBuf; - } - } - -} diff --git a/spring-web/src/main/java/org/springframework/http/client/ReactorNettyClientRequestFactory.java b/spring-web/src/main/java/org/springframework/http/client/ReactorNettyClientRequestFactory.java index 0a453d4f1a0c..a116c071385e 100644 --- a/spring-web/src/main/java/org/springframework/http/client/ReactorNettyClientRequestFactory.java +++ b/spring-web/src/main/java/org/springframework/http/client/ReactorNettyClientRequestFactory.java @@ -16,228 +16,43 @@ package org.springframework.http.client; -import java.io.IOException; -import java.net.URI; -import java.time.Duration; import java.util.function.Function; -import io.netty.channel.ChannelOption; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import reactor.netty.http.client.HttpClient; -import reactor.netty.resources.ConnectionProvider; -import reactor.netty.resources.LoopResources; - -import org.springframework.context.SmartLifecycle; -import org.springframework.http.HttpMethod; -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; /** * Reactor-Netty implementation of {@link ClientHttpRequestFactory}. * - *

        This class implements {@link SmartLifecycle} and can be optionally declared - * as a Spring-managed bean in order to support JVM Checkpoint Restore. - * * @author Arjen Poutsma - * @author Sebastien Deleuze + * @author Juergen Hoeller * @since 6.1 + * @deprecated in favor of the renamed {@link ReactorClientHttpRequestFactory} */ -public class ReactorNettyClientRequestFactory implements ClientHttpRequestFactory, SmartLifecycle { - - private static final Log logger = LogFactory.getLog(ReactorNettyClientRequestFactory.class); - - private static final Function defaultInitializer = client -> client.compress(true); - - - @Nullable - private final ReactorResourceFactory resourceFactory; - - @Nullable - private final Function mapper; - - @Nullable - private Integer connectTimeout; - - private Duration readTimeout = Duration.ofSeconds(10); - - private Duration exchangeTimeout = Duration.ofSeconds(5); - - @Nullable - private volatile HttpClient httpClient; - - private final Object lifecycleMonitor = new Object(); - +@Deprecated(since = "6.2", forRemoval = true) +public class ReactorNettyClientRequestFactory extends ReactorClientHttpRequestFactory { /** - * Create a new instance of the {@code ReactorNettyClientRequestFactory} - * with a default {@link HttpClient} that has compression enabled. + * Superseded by {@link ReactorClientHttpRequestFactory}. + * @see ReactorClientHttpRequestFactory#ReactorClientHttpRequestFactory() */ public ReactorNettyClientRequestFactory() { - this.httpClient = defaultInitializer.apply(HttpClient.create()); - this.resourceFactory = null; - this.mapper = null; + super(); } /** - * Create a new instance of the {@code ReactorNettyClientRequestFactory} - * based on the given {@link HttpClient}. - * @param httpClient the client to base on + * Superseded by {@link ReactorClientHttpRequestFactory}. + * @see ReactorClientHttpRequestFactory#ReactorClientHttpRequestFactory(HttpClient) */ public ReactorNettyClientRequestFactory(HttpClient httpClient) { - Assert.notNull(httpClient, "HttpClient must not be null"); - this.httpClient = httpClient; - this.resourceFactory = null; - this.mapper = null; + super(httpClient); } /** - * Constructor with externally managed Reactor Netty resources, including - * {@link LoopResources} for event loop threads, and {@link ConnectionProvider} - * for the connection pool. - *

        This constructor should be used only when you don't want the client - * to participate in the Reactor Netty global resources. By default the - * client participates in the Reactor Netty global resources held in - * {@link reactor.netty.http.HttpResources}, which is recommended since - * fixed, shared resources are favored for event loop concurrency. However, - * consider declaring a {@link ReactorResourceFactory} bean with - * {@code globalResources=true} in order to ensure the Reactor Netty global - * resources are shut down when the Spring ApplicationContext is stopped or closed - * and restarted properly when the Spring ApplicationContext is - * (with JVM Checkpoint Restore for example). - * @param resourceFactory the resource factory to obtain the resources from - * @param mapper a mapper for further initialization of the created client + * Superseded by {@link ReactorClientHttpRequestFactory}. + * @see ReactorClientHttpRequestFactory#ReactorClientHttpRequestFactory(ReactorResourceFactory, Function) */ public ReactorNettyClientRequestFactory(ReactorResourceFactory resourceFactory, Function mapper) { - this.resourceFactory = resourceFactory; - this.mapper = mapper; - if (resourceFactory.isRunning()) { - this.httpClient = createHttpClient(resourceFactory, mapper); - } - } - - - /** - * Set the underlying connect timeout in milliseconds. - * A value of 0 specifies an infinite timeout. - *

        Default is 30 seconds. - * @see HttpClient#option(ChannelOption, Object) - * @see ChannelOption#CONNECT_TIMEOUT_MILLIS - */ - public void setConnectTimeout(int connectTimeout) { - Assert.isTrue(connectTimeout >= 0, "Timeout must be a non-negative value"); - this.connectTimeout = connectTimeout; - HttpClient httpClient = this.httpClient; - if (httpClient != null) { - this.httpClient = httpClient.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, this.connectTimeout); - } - } - - /** - * Set the underlying connect timeout in milliseconds. - * A value of 0 specifies an infinite timeout. - *

        Default is 30 seconds. - * @see HttpClient#option(ChannelOption, Object) - * @see ChannelOption#CONNECT_TIMEOUT_MILLIS - */ - public void setConnectTimeout(Duration connectTimeout) { - Assert.notNull(connectTimeout, "ConnectTimeout must not be null"); - setConnectTimeout((int) connectTimeout.toMillis()); - } - - /** - * Set the underlying read timeout in milliseconds. - *

        Default is 10 seconds. - */ - public void setReadTimeout(long readTimeout) { - Assert.isTrue(readTimeout > 0, "Timeout must be a positive value"); - this.readTimeout = Duration.ofMillis(readTimeout); - } - - /** - * Set the underlying read timeout as {@code Duration}. - *

        Default is 10 seconds. - */ - public void setReadTimeout(Duration readTimeout) { - Assert.notNull(readTimeout, "ReadTimeout must not be null"); - Assert.isTrue(!readTimeout.isNegative(), "Timeout must be a non-negative value"); - this.readTimeout = readTimeout; - } - - /** - * Set the timeout for the HTTP exchange in milliseconds. - *

        Default is 5 seconds. - */ - public void setExchangeTimeout(long exchangeTimeout) { - Assert.isTrue(exchangeTimeout > 0, "Timeout must be a positive value"); - this.exchangeTimeout = Duration.ofMillis(exchangeTimeout); - } - - /** - * Set the timeout for the HTTP exchange. - *

        Default is 5 seconds. - */ - public void setExchangeTimeout(Duration exchangeTimeout) { - Assert.notNull(exchangeTimeout, "ExchangeTimeout must not be null"); - Assert.isTrue(!exchangeTimeout.isNegative(), "Timeout must be a non-negative value"); - this.exchangeTimeout = exchangeTimeout; - } - - private HttpClient createHttpClient(ReactorResourceFactory factory, Function mapper) { - HttpClient httpClient = defaultInitializer.andThen(mapper) - .apply(HttpClient.create(factory.getConnectionProvider())); - httpClient = httpClient.runOn(factory.getLoopResources()); - if (this.connectTimeout != null) { - httpClient = httpClient.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, this.connectTimeout); - } - return httpClient; - } - - - @Override - public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException { - HttpClient httpClient = this.httpClient; - if (httpClient == null) { - Assert.state(this.resourceFactory != null && this.mapper != null, "Illegal configuration"); - httpClient = createHttpClient(this.resourceFactory, this.mapper); - } - return new ReactorNettyClientRequest(httpClient, uri, httpMethod, this.exchangeTimeout, this.readTimeout); - } - - - @Override - public void start() { - if (this.resourceFactory != null && this.mapper != null) { - synchronized (this.lifecycleMonitor) { - if (this.httpClient == null) { - this.httpClient = createHttpClient(this.resourceFactory, this.mapper); - } - } - } - else { - logger.warn("Restarting a ReactorNettyClientRequestFactory bean is only supported " + - "with externally managed Reactor Netty resources"); - } - } - - @Override - public void stop() { - if (this.resourceFactory != null && this.mapper != null) { - synchronized (this.lifecycleMonitor) { - this.httpClient = null; - } - } - } - - @Override - public boolean isRunning() { - return (this.httpClient != null); - } - - @Override - public int getPhase() { - // Start after ReactorResourceFactory - return 1; + super(resourceFactory, mapper); } } diff --git a/spring-web/src/main/java/org/springframework/http/client/SimpleClientHttpRequestFactory.java b/spring-web/src/main/java/org/springframework/http/client/SimpleClientHttpRequestFactory.java index ec2b075bd53f..9c5982f213b9 100644 --- a/spring-web/src/main/java/org/springframework/http/client/SimpleClientHttpRequestFactory.java +++ b/spring-web/src/main/java/org/springframework/http/client/SimpleClientHttpRequestFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -62,16 +62,9 @@ public void setProxy(Proxy proxy) { /** * Indicate whether this request factory should buffer the * {@linkplain ClientHttpRequest#getBody() request body} internally. - *

        Default is {@code true}. When sending large amounts of data via POST or PUT, - * it is recommended to change this property to {@code false}, so as not to run - * out of memory. This will result in a {@link ClientHttpRequest} that either - * streams directly to the underlying {@link HttpURLConnection} (if the - * {@link org.springframework.http.HttpHeaders#getContentLength() Content-Length} - * is known in advance), or that will use "Chunked transfer encoding" - * (if the {@code Content-Length} is not known in advance). * @see #setChunkSize(int) - * @see HttpURLConnection#setFixedLengthStreamingMode(int) - * @deprecated since 6.1 requests are never buffered, as if this property is {@code false} + * @deprecated since 6.1 requests are never buffered, + * as if this property is {@code false} */ @Deprecated(since = "6.1", forRemoval = true) public void setBufferRequestBody(boolean bufferRequestBody) { @@ -80,11 +73,6 @@ public void setBufferRequestBody(boolean bufferRequestBody) { /** * Set the number of bytes to write in each chunk when not buffering request * bodies locally. - *

        Note that this parameter is only used when - * {@link #setBufferRequestBody(boolean) bufferRequestBody} is set to {@code false}, - * and the {@link org.springframework.http.HttpHeaders#getContentLength() Content-Length} - * is not known in advance. - * @see #setBufferRequestBody(boolean) */ public void setChunkSize(int chunkSize) { this.chunkSize = chunkSize; diff --git a/spring-web/src/main/java/org/springframework/http/client/SimpleClientHttpResponse.java b/spring-web/src/main/java/org/springframework/http/client/SimpleClientHttpResponse.java index 04be39a04021..728871cddd82 100644 --- a/spring-web/src/main/java/org/springframework/http/client/SimpleClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/SimpleClientHttpResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 +85,15 @@ public HttpHeaders getHeaders() { @Override public InputStream getBody() throws IOException { - InputStream errorStream = this.connection.getErrorStream(); - this.responseStream = (errorStream != null ? errorStream : this.connection.getInputStream()); + if (this.responseStream == null) { + if (this.connection.getResponseCode() >= 400) { + InputStream errorStream = this.connection.getErrorStream(); + this.responseStream = (errorStream != null) ? errorStream : InputStream.nullInputStream(); + } + else { + this.responseStream = this.connection.getInputStream(); + } + } return this.responseStream; } diff --git a/spring-web/src/main/java/org/springframework/http/client/SubscriberInputStream.java b/spring-web/src/main/java/org/springframework/http/client/SubscriberInputStream.java new file mode 100644 index 000000000000..ec3834d717f9 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/client/SubscriberInputStream.java @@ -0,0 +1,436 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.client; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ConcurrentModificationException; +import java.util.Objects; +import java.util.Queue; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.Exceptions; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * An {@link InputStream} backed by {@link Flow.Subscriber Flow.Subscriber} + * receiving byte buffers from a {@link Flow.Publisher} source. + * + *

        Byte buffers are stored in a queue. The {@code demand} constructor value + * determines the number of buffers requested initially. When storage falls + * below a {@code (demand - (demand >> 2))} limit, a request is made to refill + * the queue. + * + *

        The {@code InputStream} terminates after an onError or onComplete signal, + * and stored buffers are read. If the {@code InputStream} is closed, + * the {@link Flow.Subscription} is cancelled, and stored buffers released. + * + *

        Note that this class has a near duplicate in + * {@link org.springframework.core.io.buffer.SubscriberInputStream}. + * + * @author Oleh Dokuka + * @author Rossen Stoyanchev + * @since 6.2 + * @param the publisher byte buffer type + */ +final class SubscriberInputStream extends InputStream implements Flow.Subscriber { + + private static final Log logger = LogFactory.getLog(SubscriberInputStream.class); + + private static final Object READY = new Object(); + + private static final byte[] DONE = new byte[0]; + + private static final byte[] CLOSED = new byte[0]; + + + private final Function mapper; + + private final Consumer onDiscardHandler; + + private final int prefetch; + + private final int limit; + + private final ReentrantLock lock; + + private final Queue queue; + + private final AtomicReference parkedThread = new AtomicReference<>(); + + private final AtomicInteger workAmount = new AtomicInteger(); + + volatile boolean closed; + + private int consumed; + + @Nullable + private byte[] available; + + private int position; + + @Nullable + private Flow.Subscription subscription; + + private boolean done; + + @Nullable + private Throwable error; + + + /** + * Create an instance. + * @param mapper function to transform byte buffers to {@code byte[]}; + * the function should also release the byte buffer if necessary. + * @param onDiscardHandler a callback to release byte buffers if the + * {@link InputStream} is closed prematurely. + * @param demand the number of buffers to request initially, and buffer + * internally on an ongoing basis. + */ + SubscriberInputStream(Function mapper, Consumer onDiscardHandler, int demand) { + Assert.notNull(mapper, "mapper must not be null"); + Assert.notNull(onDiscardHandler, "onDiscardHandler must not be null"); + Assert.isTrue(demand > 0, "demand must be greater than 0"); + + this.mapper = mapper; + this.onDiscardHandler = onDiscardHandler; + this.prefetch = demand; + this.limit = (demand == Integer.MAX_VALUE ? Integer.MAX_VALUE : demand - (demand >> 2)); + this.queue = new ArrayBlockingQueue<>(demand); + this.lock = new ReentrantLock(false); + } + + + @Override + public void onSubscribe(Flow.Subscription subscription) { + if (this.subscription != null) { + subscription.cancel(); + return; + } + + this.subscription = subscription; + subscription.request(this.prefetch == Integer.MAX_VALUE ? Long.MAX_VALUE : this.prefetch); + } + + @Override + public void onNext(T buffer) { + Assert.notNull(buffer, "Buffer must not be null"); + + if (this.done) { + discard(buffer); + return; + } + + if (!this.queue.offer(buffer)) { + discard(buffer); + this.error = new RuntimeException("Buffer overflow"); + this.done = true; + } + + int previousWorkState = addWork(); + if (previousWorkState == Integer.MIN_VALUE) { + T value = this.queue.poll(); + if (value != null) { + discard(value); + } + return; + } + + if (previousWorkState == 0) { + resume(); + } + } + + @Override + public void onError(Throwable throwable) { + if (this.done) { + return; + } + this.error = throwable; + this.done = true; + + if (addWork() == 0) { + resume(); + } + } + + @Override + public void onComplete() { + if (this.done) { + return; + } + + this.done = true; + + if (addWork() == 0) { + resume(); + } + } + + int addWork() { + for (;;) { + int produced = this.workAmount.getPlain(); + + if (produced == Integer.MIN_VALUE) { + return Integer.MIN_VALUE; + } + + int nextProduced = (produced == Integer.MAX_VALUE ? 1 : produced + 1); + + if (this.workAmount.weakCompareAndSetRelease(produced, nextProduced)) { + return produced; + } + } + } + + private void resume() { + if (this.parkedThread != READY) { + Object old = this.parkedThread.getAndSet(READY); + if (old != READY) { + LockSupport.unpark((Thread) old); + } + } + } + + /* InputStream implementation */ + + @Override + public int read() throws IOException { + if (!this.lock.tryLock()) { + if (this.closed) { + return -1; + } + throw new ConcurrentModificationException("Concurrent access is not allowed"); + } + + try { + byte[] next = getNextOrAwait(); + + if (next == DONE) { + this.closed = true; + cleanAndFinalize(); + if (this.error == null) { + return -1; + } + else { + throw Exceptions.propagate(this.error); + } + } + else if (next == CLOSED) { + cleanAndFinalize(); + return -1; + } + + return next[this.position++] & 0xFF; + } + catch (Throwable ex) { + this.closed = true; + requiredSubscriber().cancel(); + cleanAndFinalize(); + throw Exceptions.propagate(ex); + } + finally { + this.lock.unlock(); + } + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + Objects.checkFromIndexSize(off, len, b.length); + if (len == 0) { + return 0; + } + + if (!this.lock.tryLock()) { + if (this.closed) { + return -1; + } + throw new ConcurrentModificationException("concurrent access is disallowed"); + } + + try { + for (int j = 0; j < len;) { + byte[] next = getNextOrAwait(); + + if (next == DONE) { + cleanAndFinalize(); + if (this.error == null) { + this.closed = true; + return j == 0 ? -1 : j; + } + else { + if (j == 0) { + this.closed = true; + throw Exceptions.propagate(this.error); + } + + return j; + } + } + else if (next == CLOSED) { + requiredSubscriber().cancel(); + cleanAndFinalize(); + return -1; + } + int i = this.position; + for (; i < next.length && j < len; i++, j++) { + b[off + j] = next[i]; + } + this.position = i; + } + + return len; + } + catch (Throwable ex) { + this.closed = true; + requiredSubscriber().cancel(); + cleanAndFinalize(); + throw Exceptions.propagate(ex); + } + finally { + this.lock.unlock(); + } + } + + byte[] getNextOrAwait() { + if (this.available == null || this.available.length - this.position == 0) { + this.available = null; + + int actualWorkAmount = this.workAmount.getAcquire(); + for (;;) { + if (this.closed) { + return CLOSED; + } + + boolean done = this.done; + T buffer = this.queue.poll(); + if (buffer != null) { + int consumed = ++this.consumed; + this.position = 0; + this.available = Objects.requireNonNull(this.mapper.apply(buffer)); + if (consumed == this.limit) { + this.consumed = 0; + requiredSubscriber().request(this.limit); + } + break; + } + + if (done) { + return DONE; + } + + actualWorkAmount = this.workAmount.addAndGet(-actualWorkAmount); + if (actualWorkAmount == 0) { + await(); + } + } + } + + return this.available; + } + + void cleanAndFinalize() { + this.available = null; + + for (;;) { + int workAmount = this.workAmount.getPlain(); + T value; + while ((value = this.queue.poll()) != null) { + discard(value); + } + + if (this.workAmount.weakCompareAndSetPlain(workAmount, Integer.MIN_VALUE)) { + return; + } + } + } + + @Override + public void close() throws IOException { + if (this.closed) { + return; + } + + this.closed = true; + + if (!this.lock.tryLock()) { + if (addWork() == 0) { + resume(); + } + return; + } + + try { + requiredSubscriber().cancel(); + cleanAndFinalize(); + } + finally { + this.lock.unlock(); + } + } + + private Flow.Subscription requiredSubscriber() { + Assert.state(this.subscription != null, "Subscriber must be subscribed to use InputStream"); + return this.subscription; + } + + void discard(T buffer) { + try { + this.onDiscardHandler.accept(buffer); + } + catch (Throwable ex) { + if (logger.isDebugEnabled()) { + logger.debug("Failed to release " + buffer.getClass().getSimpleName() + ": " + buffer, ex); + } + } + } + + private void await() { + Thread toUnpark = Thread.currentThread(); + + while (true) { + Object current = this.parkedThread.get(); + if (current == READY) { + break; + } + + if (current != null && current != toUnpark) { + throw new IllegalStateException("Only one (Virtual)Thread can await!"); + } + + if (this.parkedThread.compareAndSet( null, toUnpark)) { + LockSupport.park(); + // we don't just break here because park() can wake up spuriously + // if we got a proper resume, get() == READY and the loop will quit above + } + } + // clear the resume indicator so that the next await call will park without a resume() + this.parkedThread.lazySet(null); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/client/observation/ClientHttpObservationDocumentation.java b/spring-web/src/main/java/org/springframework/http/client/observation/ClientHttpObservationDocumentation.java index d77b46981cbb..cc14d6b90004 100644 --- a/spring-web/src/main/java/org/springframework/http/client/observation/ClientHttpObservationDocumentation.java +++ b/spring-web/src/main/java/org/springframework/http/client/observation/ClientHttpObservationDocumentation.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,11 @@ /** - * Documented {@link io.micrometer.common.KeyValue KeyValues} for {@link ClientHttpRequestFactory HTTP client} observations. - *

        This class is used by automated tools to document KeyValues attached to the HTTP client observations. + * Documented {@link io.micrometer.common.KeyValue KeyValues} for + * {@link ClientHttpRequestFactory HTTP client} observations. + * + *

        This class is used by automated tools to document KeyValues attached to the + * HTTP client observations. * * @author Brian Clozel * @since 6.0 @@ -52,25 +55,26 @@ public KeyName[] getLowCardinalityKeyNames() { public KeyName[] getHighCardinalityKeyNames() { return new KeyName[] {HighCardinalityKeyNames.HTTP_URL}; } - }; + public enum LowCardinalityKeyNames implements KeyName { /** - * Name of HTTP request method or {@value KeyValue#NONE_VALUE} if the request could not be created. + * Name of HTTP request method or {@value KeyValue#NONE_VALUE} if the + * request could not be created. */ METHOD { @Override public String asString() { return "method"; } - }, /** - * URI template used for HTTP request, or {@value KeyValue#NONE_VALUE} if none was provided. - * Only the path part of the URI is considered. + * URI template used for HTTP request, or {@value KeyValue#NONE_VALUE} if + * none was provided. + *

        The protocol, host and port part of the URI are not considered. */ URI { @Override @@ -90,7 +94,6 @@ public String asString() { } }, - /** * Client name derived from the request URI host. * @since 6.0.5 @@ -103,7 +106,8 @@ public String asString() { }, /** - * Name of the exception thrown during the exchange, or {@value KeyValue#NONE_VALUE} if no exception happened. + * Name of the exception thrown during the exchange, or + * {@value KeyValue#NONE_VALUE} if no exception happened. */ EXCEPTION { @Override @@ -135,20 +139,6 @@ public enum HighCardinalityKeyNames implements KeyName { public String asString() { return "http.url"; } - }, - - /** - * Client name derived from the request URI host. - * @deprecated in favor of {@link LowCardinalityKeyNames#CLIENT_NAME}; - * scheduled for removal in 6.2. This will be available both as a low and - * high cardinality key value. - */ - @Deprecated(since = "6.0.5", forRemoval = true) - CLIENT_NAME { - @Override - public String asString() { - return "client.name"; - } } } diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/AbstractClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/reactive/AbstractClientHttpRequest.java index ecc2faaaf812..63db636c0a55 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/AbstractClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/AbstractClientHttpRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +17,10 @@ package org.springframework.http.client.reactive; import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; @@ -55,6 +58,8 @@ private enum State {NEW, COMMITTING, COMMITTED} private final MultiValueMap cookies; + private final Map attributes; + private final AtomicReference state = new AtomicReference<>(State.NEW); private final List>> commitActions = new ArrayList<>(4); @@ -71,6 +76,7 @@ public AbstractClientHttpRequest(HttpHeaders headers) { Assert.notNull(headers, "HttpHeaders must not be null"); this.headers = headers; this.cookies = new LinkedMultiValueMap<>(); + this.attributes = new LinkedHashMap<>(); } @@ -106,6 +112,14 @@ public MultiValueMap getCookies() { return this.cookies; } + @Override + public Map getAttributes() { + if (State.COMMITTED.equals(this.state.get())) { + return Collections.unmodifiableMap(this.attributes); + } + return this.attributes; + } + @Override public void beforeCommit(Supplier> action) { Assert.notNull(action, "Action must not be null"); @@ -140,6 +154,7 @@ protected Mono doCommit(@Nullable Supplier> writ Mono.fromRunnable(() -> { applyHeaders(); applyCookies(); + applyAttributes(); this.state.set(State.COMMITTED); })); @@ -168,4 +183,12 @@ protected Mono doCommit(@Nullable Supplier> writ */ protected abstract void applyCookies(); + /** + * Add attributes from {@link #getAttributes()} to the underlying request. + * This method is called once only. + * @since 6.2 + */ + protected void applyAttributes() { + } + } diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpRequest.java index 7a0183b870a9..9f8c7aa17f84 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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.client.reactive; import java.net.URI; +import java.util.Map; import org.springframework.http.HttpCookie; import org.springframework.http.HttpMethod; @@ -47,6 +48,12 @@ public interface ClientHttpRequest extends ReactiveHttpOutputMessage { */ MultiValueMap getCookies(); + /** + * Return a mutable map of the request attributes. + * @since 6.2 + */ + Map getAttributes(); + /** * Return the request from the underlying HTTP library. * @param the expected type of the request to cast to diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpRequestDecorator.java b/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpRequestDecorator.java index cb2948ac7d55..388c628271e8 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpRequestDecorator.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpRequestDecorator.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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.client.reactive; import java.net.URI; +import java.util.Map; import java.util.function.Supplier; import org.reactivestreams.Publisher; @@ -75,6 +76,11 @@ public MultiValueMap getCookies() { return this.delegate.getCookies(); } + @Override + public Map getAttributes() { + return this.delegate.getAttributes(); + } + @Override public DataBufferFactory bufferFactory() { return this.delegate.bufferFactory(); diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpConnector.java b/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpConnector.java index 6e445868c2db..3a8368633369 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpConnector.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpConnector.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +24,6 @@ import java.util.function.BiFunction; import java.util.function.Function; -import org.apache.hc.client5.http.cookie.BasicCookieStore; import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; import org.apache.hc.client5.http.impl.async.HttpAsyncClients; import org.apache.hc.client5.http.protocol.HttpClientContext; @@ -107,12 +106,10 @@ public Mono connect(HttpMethod method, URI uri, Function> requestCallback) { HttpClientContext context = this.contextProvider.apply(method, uri); - if (context.getCookieStore() == null) { - context.setCookieStore(new BasicCookieStore()); - } HttpComponentsClientHttpRequest request = new HttpComponentsClientHttpRequest(method, uri, context, this.dataBufferFactory); + return requestCallback.apply(request).then(Mono.defer(() -> execute(request, context))); } diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpRequest.java index 92e20d32c353..c878792c0f2a 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,11 +19,9 @@ import java.net.URI; import java.net.URISyntaxException; import java.nio.ByteBuffer; -import java.util.Collection; +import java.util.List; import java.util.function.Function; -import org.apache.hc.client5.http.cookie.CookieStore; -import org.apache.hc.client5.http.impl.cookie.BasicClientCookie; import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.HttpRequest; @@ -37,11 +35,13 @@ import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.HttpCookie; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.support.HttpComponentsHeadersAdapter; import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; /** * {@link ClientHttpRequest} implementation for the Apache HttpComponents HttpClient 5.x. @@ -143,18 +143,38 @@ protected void applyCookies() { if (getCookies().isEmpty()) { return; } + if (!CollectionUtils.isEmpty(getCookies())) { + this.httpRequest.setHeader(HttpHeaders.COOKIE, serializeCookies()); + } + } - CookieStore cookieStore = this.context.getCookieStore(); + private String serializeCookies() { + boolean first = true; + StringBuilder sb = new StringBuilder(); + for (List cookies : getCookies().values()) { + for (HttpCookie cookie : cookies) { + if (!first) { + sb.append("; "); + } + else { + first = false; + } + sb.append(cookie.getName()).append("=").append(cookie.getValue()); + } + } + return sb.toString(); + } - getCookies().values() - .stream() - .flatMap(Collection::stream) - .forEach(cookie -> { - BasicClientCookie clientCookie = new BasicClientCookie(cookie.getName(), cookie.getValue()); - clientCookie.setDomain(getURI().getHost()); - clientCookie.setPath(getURI().getPath()); - cookieStore.addCookie(clientCookie); - }); + /** + * Applies the attributes to the {@link HttpClientContext}. + */ + @Override + protected void applyAttributes() { + getAttributes().forEach((key, value) -> { + if (this.context.getAttribute(key) == null) { + this.context.setAttribute(key, value); + } + }); } @Override diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpResponse.java b/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpResponse.java index 52e281333936..54c5da2c052f 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpResponse.java @@ -21,9 +21,15 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; +import java.util.Iterator; +import java.util.List; import org.apache.hc.client5.http.cookie.Cookie; +import org.apache.hc.client5.http.cookie.CookieOrigin; +import org.apache.hc.client5.http.cookie.CookieSpec; +import org.apache.hc.client5.http.cookie.MalformedCookieException; import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.Message; import org.reactivestreams.Publisher; @@ -53,23 +59,50 @@ public HttpComponentsClientHttpResponse(DataBufferFactory dataBufferFactory, super(HttpStatusCode.valueOf(message.getHead().getCode()), HttpHeaders.readOnlyHttpHeaders(new HttpComponentsHeadersAdapter(message.getHead())), - adaptCookies(context), + adaptCookies(message.getHead(), context), Flux.from(message.getBody()).map(dataBufferFactory::wrap) ); } - private static MultiValueMap adaptCookies(HttpClientContext context) { + private static MultiValueMap adaptCookies( + HttpResponse response, HttpClientContext context) { + LinkedMultiValueMap result = new LinkedMultiValueMap<>(); - context.getCookieStore().getCookies().forEach(cookie -> - result.add(cookie.getName(), - ResponseCookie.fromClientResponse(cookie.getName(), cookie.getValue()) - .domain(cookie.getDomain()) - .path(cookie.getPath()) - .maxAge(getMaxAgeSeconds(cookie)) - .secure(cookie.isSecure()) - .httpOnly(cookie.containsAttribute("httponly")) - .sameSite(cookie.getAttribute("samesite")) - .build())); + + CookieSpec cookieSpec = context.getCookieSpec(); + if (cookieSpec == null) { + return result; + } + + CookieOrigin cookieOrigin = context.getCookieOrigin(); + Iterator

        itr = response.headerIterator(HttpHeaders.SET_COOKIE); + while (itr.hasNext()) { + Header header = itr.next(); + try { + List cookies = cookieSpec.parse(header, cookieOrigin); + for (Cookie cookie : cookies) { + try { + cookieSpec.validate(cookie, cookieOrigin); + result.add(cookie.getName(), + ResponseCookie.fromClientResponse(cookie.getName(), cookie.getValue()) + .domain(cookie.getDomain()) + .path(cookie.getPath()) + .maxAge(getMaxAgeSeconds(cookie)) + .secure(cookie.isSecure()) + .httpOnly(cookie.containsAttribute("httponly")) + .sameSite(cookie.getAttribute("samesite")) + .build()); + } + catch (final MalformedCookieException ex) { + // ignore invalid cookie + } + } + } + catch (final MalformedCookieException ex) { + // ignore invalid cookie + } + } + return result; } diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpConnector.java b/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpConnector.java index 4313c658076d..5b6f08de1051 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpConnector.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpConnector.java @@ -21,6 +21,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.ByteBuffer; +import java.time.Duration; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; @@ -49,6 +50,9 @@ public class JdkClientHttpConnector implements ClientHttpConnector { private DataBufferFactory bufferFactory = DefaultDataBufferFactory.sharedInstance; + @Nullable + private Duration readTimeout; + /** * Default constructor that uses {@link HttpClient#newHttpClient()}. @@ -91,12 +95,24 @@ public void setBufferFactory(DataBufferFactory bufferFactory) { this.bufferFactory = bufferFactory; } + /** + * Set the underlying {@code HttpClient}'s read timeout as a {@code Duration}. + *

        Default is the system's default timeout. + * @since 6.2 + * @see java.net.http.HttpRequest.Builder#timeout + */ + public void setReadTimeout(Duration readTimeout) { + Assert.notNull(readTimeout, "readTimeout is required"); + this.readTimeout = readTimeout; + } + @Override public Mono connect( HttpMethod method, URI uri, Function> requestCallback) { - JdkClientHttpRequest jdkClientHttpRequest = new JdkClientHttpRequest(method, uri, this.bufferFactory); + JdkClientHttpRequest jdkClientHttpRequest = new JdkClientHttpRequest(method, uri, this.bufferFactory, + this.readTimeout); return requestCallback.apply(jdkClientHttpRequest).then(Mono.defer(() -> { HttpRequest httpRequest = jdkClientHttpRequest.getNativeRequest(); diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpRequest.java index 2295d4ba2cb9..9eeebb6b2755 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/JdkClientHttpRequest.java @@ -20,6 +20,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.nio.ByteBuffer; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.concurrent.Flow; @@ -36,6 +37,7 @@ import org.springframework.http.HttpCookie; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; @@ -57,7 +59,8 @@ class JdkClientHttpRequest extends AbstractClientHttpRequest { private final HttpRequest.Builder builder; - public JdkClientHttpRequest(HttpMethod httpMethod, URI uri, DataBufferFactory bufferFactory) { + public JdkClientHttpRequest(HttpMethod httpMethod, URI uri, DataBufferFactory bufferFactory, + @Nullable Duration readTimeout) { Assert.notNull(httpMethod, "HttpMethod is required"); Assert.notNull(uri, "URI is required"); Assert.notNull(bufferFactory, "DataBufferFactory is required"); @@ -66,6 +69,9 @@ public JdkClientHttpRequest(HttpMethod httpMethod, URI uri, DataBufferFactory bu this.uri = uri; this.bufferFactory = bufferFactory; this.builder = HttpRequest.newBuilder(uri); + if (readTimeout != null) { + this.builder.timeout(readTimeout); + } } diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpConnector.java b/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpConnector.java index d985ffbe1777..ca255fe97375 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpConnector.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpConnector.java @@ -17,24 +17,16 @@ package org.springframework.http.client.reactive; import java.net.URI; -import java.nio.ByteBuffer; -import java.nio.charset.Charset; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.IntPredicate; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.Request; -import org.eclipse.jetty.io.Content; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.core.io.buffer.DataBufferFactory; -import org.springframework.core.io.buffer.DefaultDataBufferFactory; -import org.springframework.core.io.buffer.PooledDataBuffer; -import org.springframework.core.io.buffer.TouchableDataBuffer; +import org.springframework.core.io.buffer.JettyDataBufferFactory; import org.springframework.http.HttpMethod; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -50,7 +42,7 @@ public class JettyClientHttpConnector implements ClientHttpConnector { private final HttpClient httpClient; - private DataBufferFactory bufferFactory = DefaultDataBufferFactory.sharedInstance; + private JettyDataBufferFactory bufferFactory = new JettyDataBufferFactory(); /** @@ -103,7 +95,7 @@ public JettyClientHttpConnector(JettyResourceFactory resourceFactory, @Nullable /** * Set the buffer factory to use. */ - public void setBufferFactory(DataBufferFactory bufferFactory) { + public void setBufferFactory(JettyDataBufferFactory bufferFactory) { this.bufferFactory = bufferFactory; } @@ -134,286 +126,9 @@ public Mono connect(HttpMethod method, URI uri, private Mono execute(JettyClientHttpRequest request) { return Mono.fromDirect(request.toReactiveRequest() .response((reactiveResponse, chunkPublisher) -> { - Flux content = Flux.from(chunkPublisher).map(this::toDataBuffer); + Flux content = Flux.from(chunkPublisher).map(this.bufferFactory::wrap); return Mono.just(new JettyClientHttpResponse(reactiveResponse, content)); })); } - private DataBuffer toDataBuffer(Content.Chunk chunk) { - DataBuffer delegate = this.bufferFactory.wrap(chunk.getByteBuffer()); - return new JettyDataBuffer(delegate, chunk); - } - - - private static final class JettyDataBuffer implements PooledDataBuffer { - - private final DataBuffer delegate; - - private final Content.Chunk chunk; - - private final AtomicInteger refCount = new AtomicInteger(1); - - public JettyDataBuffer(DataBuffer delegate, Content.Chunk chunk) { - Assert.notNull(delegate, "Delegate must not be null"); - Assert.notNull(chunk, "Chunk must not be null"); - - this.delegate = delegate; - this.chunk = chunk; - } - - @Override - public boolean isAllocated() { - return this.refCount.get() > 0; - } - - @Override - public PooledDataBuffer retain() { - if (this.delegate instanceof PooledDataBuffer pooledDelegate) { - pooledDelegate.retain(); - } - this.chunk.retain(); - this.refCount.getAndUpdate(c -> { - if (c != 0) { - return c + 1; - } - else { - return 0; - } - }); - return this; - } - - @Override - public boolean release() { - if (this.delegate instanceof PooledDataBuffer pooledDelegate) { - pooledDelegate.release(); - } - this.chunk.release(); - int refCount = this.refCount.updateAndGet(c -> { - if (c != 0) { - return c - 1; - } - else { - throw new IllegalStateException("already released " + this); - } - }); - return refCount == 0; - } - - @Override - public PooledDataBuffer touch(Object hint) { - if (this.delegate instanceof TouchableDataBuffer touchableDelegate) { - touchableDelegate.touch(hint); - } - return this; - } - - // delegation - - @Override - public DataBufferFactory factory() { - return this.delegate.factory(); - } - - @Override - public int indexOf(IntPredicate predicate, int fromIndex) { - return this.delegate.indexOf(predicate, fromIndex); - } - - @Override - public int lastIndexOf(IntPredicate predicate, int fromIndex) { - return this.delegate.lastIndexOf(predicate, fromIndex); - } - - @Override - public int readableByteCount() { - return this.delegate.readableByteCount(); - } - - @Override - public int writableByteCount() { - return this.delegate.writableByteCount(); - } - - @Override - public int capacity() { - return this.delegate.capacity(); - } - - @Override - @Deprecated - public DataBuffer capacity(int capacity) { - this.delegate.capacity(capacity); - return this; - } - - @Override - public DataBuffer ensureWritable(int capacity) { - this.delegate.ensureWritable(capacity); - return this; - } - - @Override - public int readPosition() { - return this.delegate.readPosition(); - } - - @Override - public DataBuffer readPosition(int readPosition) { - this.delegate.readPosition(readPosition); - return this; - } - - @Override - public int writePosition() { - return this.delegate.writePosition(); - } - - @Override - public DataBuffer writePosition(int writePosition) { - this.delegate.writePosition(writePosition); - return this; - } - - @Override - public byte getByte(int index) { - return this.delegate.getByte(index); - } - - @Override - public byte read() { - return this.delegate.read(); - } - - @Override - public DataBuffer read(byte[] destination) { - this.delegate.read(destination); - return this; - } - - @Override - public DataBuffer read(byte[] destination, int offset, int length) { - this.delegate.read(destination, offset, length); - return this; - } - - @Override - public DataBuffer write(byte b) { - this.delegate.write(b); - return this; - } - - @Override - public DataBuffer write(byte[] source) { - this.delegate.write(source); - return this; - } - - @Override - public DataBuffer write(byte[] source, int offset, int length) { - this.delegate.write(source, offset, length); - return this; - } - - @Override - public DataBuffer write(DataBuffer... buffers) { - this.delegate.write(buffers); - return this; - } - - @Override - public DataBuffer write(ByteBuffer... buffers) { - this.delegate.write(buffers); - return this; - } - - @Override - @Deprecated - public DataBuffer slice(int index, int length) { - DataBuffer delegateSlice = this.delegate.slice(index, length); - this.chunk.retain(); - return new JettyDataBuffer(delegateSlice, this.chunk); - } - - @Override - public DataBuffer split(int index) { - DataBuffer delegateSplit = this.delegate.split(index); - this.chunk.retain(); - return new JettyDataBuffer(delegateSplit, this.chunk); - } - - @Override - @Deprecated - public ByteBuffer asByteBuffer() { - return this.delegate.asByteBuffer(); - } - - @Override - @Deprecated - public ByteBuffer asByteBuffer(int index, int length) { - return this.delegate.asByteBuffer(index, length); - } - - @Override - @Deprecated - public ByteBuffer toByteBuffer(int index, int length) { - return this.delegate.toByteBuffer(index, length); - } - - @Override - public void toByteBuffer(int srcPos, ByteBuffer dest, int destPos, int length) { - this.delegate.toByteBuffer(srcPos, dest, destPos, length); - } - - @Override - public ByteBufferIterator readableByteBuffers() { - ByteBufferIterator delegateIterator = this.delegate.readableByteBuffers(); - return new JettyByteBufferIterator(delegateIterator, this.chunk); - } - - @Override - public ByteBufferIterator writableByteBuffers() { - ByteBufferIterator delegateIterator = this.delegate.writableByteBuffers(); - return new JettyByteBufferIterator(delegateIterator, this.chunk); - } - - @Override - public String toString(int index, int length, Charset charset) { - return this.delegate.toString(index, length, charset); - } - - - private static final class JettyByteBufferIterator implements ByteBufferIterator { - - private final ByteBufferIterator delegate; - - private final Content.Chunk chunk; - - public JettyByteBufferIterator(ByteBufferIterator delegate, Content.Chunk chunk) { - Assert.notNull(delegate, "Delegate must not be null"); - Assert.notNull(chunk, "Chunk must not be null"); - - this.delegate = delegate; - this.chunk = chunk; - this.chunk.retain(); - } - - @Override - public void close() { - this.delegate.close(); - this.chunk.release(); - } - - @Override - public boolean hasNext() { - return this.delegate.hasNext(); - } - - @Override - public ByteBuffer next() { - return this.delegate.next(); - } - } - } - } diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpRequest.java index d5e934c2bd89..573e34e4d61e 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -154,6 +154,15 @@ protected HttpHeaders initReadOnlyHeaders() { return HttpHeaders.readOnlyHttpHeaders(new JettyHeadersAdapter(this.jettyRequest.getHeaders())); } + @Override + protected void applyAttributes() { + getAttributes().forEach((key, value) -> { + if (this.jettyRequest.getAttributes().get(key) == null) { + this.jettyRequest.attribute(key, value); + } + }); + } + public ReactiveRequest toReactiveRequest() { return this.builder.build(); } diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpConnector.java b/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpConnector.java index 2a3db3e7595b..db838d6ad066 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpConnector.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpConnector.java @@ -17,9 +17,11 @@ package org.springframework.http.client.reactive; import java.net.URI; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; +import io.netty.util.AttributeKey; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import reactor.core.publisher.Mono; @@ -50,6 +52,13 @@ */ public class ReactorClientHttpConnector implements ClientHttpConnector, SmartLifecycle { + /** + * Channel attribute key under which {@code WebClient} request attributes are stored as a Map. + * @since 6.2 + */ + public static final AttributeKey> ATTRIBUTES_KEY = + AttributeKey.valueOf(ReactorClientHttpRequest.class.getName() + ".ATTRIBUTES"); + private static final Log logger = LogFactory.getLog(ReactorClientHttpConnector.class); private static final Function defaultInitializer = client -> client.compress(true); diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpRequest.java index 6895ad30a827..646ba71adf07 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +25,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.netty.NettyOutbound; +import reactor.netty.channel.ChannelOperations; import reactor.netty.http.client.HttpClientRequest; import org.springframework.core.io.buffer.DataBuffer; @@ -119,7 +120,9 @@ public Mono writeWith(Path file, long position, long count) { @Override public Mono setComplete() { - return doCommit(this.outbound::then); + // NettyOutbound#then() expects a body + // Use null as the write action for a more optimal send + return doCommit(null); } @Override @@ -135,6 +138,19 @@ protected void applyCookies() { })); } + /** + * Saves the {@link #getAttributes() request attributes} to the + * {@link reactor.netty.channel.ChannelOperations#channel() channel} as a single map + * attribute under the key {@link ReactorClientHttpConnector#ATTRIBUTES_KEY}. + */ + @Override + protected void applyAttributes() { + if (!getAttributes().isEmpty()) { + ((ChannelOperations) this.request).channel() + .attr(ReactorClientHttpConnector.ATTRIBUTES_KEY).set(getAttributes()); + } + } + @Override protected HttpHeaders initReadOnlyHeaders() { return HttpHeaders.readOnlyHttpHeaders(new Netty4HeadersAdapter(this.request.requestHeaders())); diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorNetty2ClientHttpConnector.java b/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorNetty2ClientHttpConnector.java index 4bed71fbec88..6a910ba0552c 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorNetty2ClientHttpConnector.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorNetty2ClientHttpConnector.java @@ -17,9 +17,11 @@ package org.springframework.http.client.reactive; import java.net.URI; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; +import io.netty5.util.AttributeKey; import reactor.core.publisher.Mono; import reactor.netty5.NettyOutbound; import reactor.netty5.http.client.HttpClient; @@ -41,6 +43,13 @@ */ public class ReactorNetty2ClientHttpConnector implements ClientHttpConnector { + /** + * Channel attribute key under which {@code WebClient} request attributes are stored as a Map. + * @since 6.2 + */ + public static final AttributeKey> ATTRIBUTES_KEY = + AttributeKey.valueOf(ReactorNetty2ClientHttpRequest.class.getName() + ".ATTRIBUTES"); + private static final Function defaultInitializer = client -> client.compress(true); diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorNetty2ClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorNetty2ClientHttpRequest.java index 749326fa5f18..f8fab77ba67a 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorNetty2ClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorNetty2ClientHttpRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +25,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.netty5.NettyOutbound; +import reactor.netty5.channel.ChannelOperations; import reactor.netty5.http.client.HttpClientRequest; import org.springframework.core.io.buffer.DataBuffer; @@ -57,7 +58,9 @@ class ReactorNetty2ClientHttpRequest extends AbstractClientHttpRequest implement private final Netty5DataBufferFactory bufferFactory; - public ReactorNetty2ClientHttpRequest(HttpMethod method, URI uri, HttpClientRequest request, NettyOutbound outbound) { + public ReactorNetty2ClientHttpRequest( + HttpMethod method, URI uri, HttpClientRequest request, NettyOutbound outbound) { + this.httpMethod = method; this.uri = uri; this.request = request; @@ -120,7 +123,9 @@ public Mono writeWith(Path file, long position, long count) { @Override public Mono setComplete() { - return doCommit(this.outbound::then); + // NettyOutbound#then() expects a body + // Use null as the write action for a more optimal send + return doCommit(null); } @Override @@ -136,6 +141,19 @@ protected void applyCookies() { })); } + /** + * Saves the {@link #getAttributes() request attributes} to the + * {@link reactor.netty.channel.ChannelOperations#channel() channel} as a single map + * attribute under the key {@link ReactorNetty2ClientHttpConnector#ATTRIBUTES_KEY}. + */ + @Override + protected void applyAttributes() { + if (!getAttributes().isEmpty()) { + ((ChannelOperations) this.request).channel() + .attr(ReactorNetty2ClientHttpConnector.ATTRIBUTES_KEY).set(getAttributes()); + } + } + @Override protected HttpHeaders initReadOnlyHeaders() { return HttpHeaders.readOnlyHttpHeaders(new Netty5HeadersAdapter(this.request.requestHeaders())); diff --git a/spring-web/src/main/java/org/springframework/http/client/support/HttpAccessor.java b/spring-web/src/main/java/org/springframework/http/client/support/HttpAccessor.java index 1ac8b7969c8d..58d8cec61ae9 100644 --- a/spring-web/src/main/java/org/springframework/http/client/support/HttpAccessor.java +++ b/spring-web/src/main/java/org/springframework/http/client/support/HttpAccessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -67,7 +67,7 @@ public abstract class HttpAccessor { * @see #createRequest(URI, HttpMethod) * @see SimpleClientHttpRequestFactory * @see org.springframework.http.client.HttpComponentsClientHttpRequestFactory - * @see org.springframework.http.client.OkHttp3ClientHttpRequestFactory + * @see org.springframework.http.client.JdkClientHttpRequestFactory */ public void setRequestFactory(ClientHttpRequestFactory requestFactory) { Assert.notNull(requestFactory, "ClientHttpRequestFactory must not be null"); diff --git a/spring-web/src/main/java/org/springframework/http/client/support/HttpRequestWrapper.java b/spring-web/src/main/java/org/springframework/http/client/support/HttpRequestWrapper.java index dad91a6b1b0c..57120d15c509 100644 --- a/spring-web/src/main/java/org/springframework/http/client/support/HttpRequestWrapper.java +++ b/spring-web/src/main/java/org/springframework/http/client/support/HttpRequestWrapper.java @@ -17,6 +17,7 @@ package org.springframework.http.client.support; import java.net.URI; +import java.util.Map; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -70,6 +71,14 @@ public URI getURI() { return this.request.getURI(); } + /** + * Return the attributes of the wrapped request. + */ + @Override + public Map getAttributes() { + return this.request.getAttributes(); + } + /** * Return the headers of the wrapped request. */ diff --git a/spring-web/src/main/java/org/springframework/http/codec/ClientCodecConfigurer.java b/spring-web/src/main/java/org/springframework/http/codec/ClientCodecConfigurer.java index e2760e2182d9..858ea638bc89 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/ClientCodecConfigurer.java +++ b/spring-web/src/main/java/org/springframework/http/codec/ClientCodecConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +63,7 @@ public interface ClientCodecConfigurer extends CodecConfigurer { ClientDefaultCodecs defaultCodecs(); /** - * {@inheritDoc}. + * {@inheritDoc} */ @Override ClientCodecConfigurer clone(); diff --git a/spring-web/src/main/java/org/springframework/http/codec/CodecConfigurer.java b/spring-web/src/main/java/org/springframework/http/codec/CodecConfigurer.java index a5906a49b697..a309838cc880 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/CodecConfigurer.java +++ b/spring-web/src/main/java/org/springframework/http/codec/CodecConfigurer.java @@ -39,9 +39,9 @@ *

        HTTP message readers and writers are divided into 3 categories that are * ordered as follows: *

          - *
        1. Typed readers and writers that support specific types, e.g. byte[], String. - *
        2. Object readers and writers, e.g. JSON, XML. - *
        3. Catch-all readers or writers, e.g. String with any media type. + *
        4. Typed readers and writers that support specific types, for example, byte[], String. + *
        5. Object readers and writers, for example, JSON, XML. + *
        6. Catch-all readers or writers, for example, String with any media type. *
        * *

        Typed and object readers are further subdivided and ordered as follows: @@ -241,7 +241,7 @@ interface DefaultCodecs { * decoding to a single {@code DataBuffer}, * {@link java.nio.ByteBuffer ByteBuffer}, {@code byte[]}, * {@link org.springframework.core.io.Resource Resource}, {@code String}, etc. - * It can also occur when splitting the input stream, e.g. delimited text, + * It can also occur when splitting the input stream, for example, delimited text, * in which case the limit applies to data buffered between delimiters. *

        By default this is not set, in which case individual codec defaults * apply. All codecs are limited to 256K by default. diff --git a/spring-web/src/main/java/org/springframework/http/codec/EncoderHttpMessageWriter.java b/spring-web/src/main/java/org/springframework/http/codec/EncoderHttpMessageWriter.java index 239ddf0a69f9..fc791caab737 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/EncoderHttpMessageWriter.java +++ b/spring-web/src/main/java/org/springframework/http/codec/EncoderHttpMessageWriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -127,6 +127,7 @@ public Mono write(Publisher inputStream, ResolvableType eleme return body .singleOrEmpty() .switchIfEmpty(Mono.defer(() -> { + message.getHeaders().setContentType(null); message.getHeaders().setContentLength(0); return message.setComplete().then(Mono.empty()); })) diff --git a/spring-web/src/main/java/org/springframework/http/codec/FormHttpMessageWriter.java b/spring-web/src/main/java/org/springframework/http/codec/FormHttpMessageWriter.java index f4867dcb82ec..4b313089d774 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/FormHttpMessageWriter.java +++ b/spring-web/src/main/java/org/springframework/http/codec/FormHttpMessageWriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author 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,14 +60,9 @@ public class FormHttpMessageWriter extends LoggingCodecSupport implements HttpMessageWriter> { - /** - * The default charset used by the writer. - */ + /** The default charset used by the writer. */ public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; - private static final MediaType DEFAULT_FORM_DATA_MEDIA_TYPE = - new MediaType(MediaType.APPLICATION_FORM_URLENCODED, DEFAULT_CHARSET); - private static final List MEDIA_TYPES = Collections.singletonList(MediaType.APPLICATION_FORM_URLENCODED); @@ -126,7 +121,7 @@ public Mono write(Publisher> input mediaType = getMediaType(mediaType); message.getHeaders().setContentType(mediaType); - Charset charset = mediaType.getCharset() != null ? mediaType.getCharset() : getDefaultCharset(); + Charset charset = (mediaType.getCharset() != null ? mediaType.getCharset() : getDefaultCharset()); return Mono.from(inputStream).flatMap(form -> { logFormData(form, hints); @@ -138,16 +133,22 @@ public Mono write(Publisher> input }); } + /** + * Return the content type used to write forms, either the given media type + * or otherwise {@code application/x-www-form-urlencoded}. + * @param mediaType the media type passed to {@link #write}, or {@code null} + * @return the content type to use + */ protected MediaType getMediaType(@Nullable MediaType mediaType) { if (mediaType == null) { - return DEFAULT_FORM_DATA_MEDIA_TYPE; - } - else if (mediaType.getCharset() == null) { - return new MediaType(mediaType, getDefaultCharset()); + return MediaType.APPLICATION_FORM_URLENCODED; } - else { - return mediaType; + // Some servers don't handle charset parameter and spec is unclear, + // Add it only if it is not DEFAULT_CHARSET. + if (mediaType.getCharset() == null && this.defaultCharset != DEFAULT_CHARSET) { + return new MediaType(mediaType, this.defaultCharset); } + return mediaType; } private void logFormData(MultiValueMap form, Map hints) { diff --git a/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationBinaryDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationBinaryDecoder.java index 6d703f96bf7c..b0c05318d00c 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationBinaryDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationBinaryDecoder.java @@ -60,7 +60,7 @@ public KotlinSerializationBinaryDecoder(T format, MimeType... supportedMimeTypes * decoding to a single {@code DataBuffer}, * {@link java.nio.ByteBuffer ByteBuffer}, {@code byte[]}, * {@link org.springframework.core.io.Resource Resource}, {@code String}, etc. - * It can also occur when splitting the input stream, e.g. delimited text, + * It can also occur when splitting the input stream, for example, delimited text, * in which case the limit applies to data buffered between delimiters. *

        By default this is set to 256K. * @param byteCount the max number of bytes to buffer, or -1 for unlimited diff --git a/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringDecoder.java index 4b71dadf7a56..79f42b43fb4e 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringDecoder.java @@ -61,7 +61,7 @@ public KotlinSerializationStringDecoder(T format, MimeType... supportedMimeTypes * decoding to a single {@code DataBuffer}, * {@link java.nio.ByteBuffer ByteBuffer}, {@code byte[]}, * {@link org.springframework.core.io.Resource Resource}, {@code String}, etc. - * It can also occur when splitting the input stream, e.g. delimited text, + * It can also occur when splitting the input stream, for example, delimited text, * in which case the limit applies to data buffered between delimiters. *

        By default this is set to 256K. * @param byteCount the max number of bytes to buffer, or -1 for unlimited diff --git a/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringEncoder.java index 82007c3ee7bb..f9ade37bce43 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringEncoder.java @@ -16,13 +16,13 @@ package org.springframework.http.codec; +import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; -import kotlin.text.Charsets; import kotlinx.serialization.KSerializer; import kotlinx.serialization.StringFormat; import org.reactivestreams.Publisher; @@ -52,6 +52,11 @@ public abstract class KotlinSerializationStringEncoder extends KotlinSerializationSupport implements Encoder { + private static final byte[] NEWLINE_SEPARATOR = {'\n'}; + + protected static final byte[] EMPTY_BYTES = new byte[0]; + + // CharSequence encoding needed for now, see https://github.com/Kotlin/kotlinx.serialization/issues/204 for more details private final CharSequenceEncoder charSequenceEncoder = CharSequenceEncoder.allMimeTypes(); private final Set streamingMediaTypes = new HashSet<>(); @@ -85,22 +90,40 @@ public List getEncodableMimeTypes(ResolvableType elementType) { return supportedMimeTypes(); } - @Override public Flux encode(Publisher inputStream, DataBufferFactory bufferFactory, - ResolvableType elementType, - @Nullable MimeType mimeType, @Nullable Map hints) { - if (inputStream instanceof Mono) { - return Mono.from(inputStream) + ResolvableType elementType, @Nullable MimeType mimeType, @Nullable Map hints) { + + if (inputStream instanceof Mono mono) { + return mono .map(value -> encodeValue(value, bufferFactory, elementType, mimeType, hints)) .flux(); } - if (mimeType != null && this.streamingMediaTypes.contains(mimeType)) { return Flux.from(inputStream) - .map(list -> encodeValue(list, bufferFactory, elementType, mimeType, hints) - .write("\n", Charsets.UTF_8)); + .map(value -> encodeStreamingValue(value, bufferFactory, elementType, mimeType, hints, EMPTY_BYTES, + NEWLINE_SEPARATOR)); } + return encodeNonStream(inputStream, bufferFactory, elementType, mimeType, hints); + } + + protected DataBuffer encodeStreamingValue(Object value, DataBufferFactory bufferFactory, + ResolvableType valueType, @Nullable MimeType mimeType, + @Nullable Map hints, byte[] prefix, byte[] suffix) { + + List buffers = new ArrayList<>(3); + if (prefix.length > 0) { + buffers.add(bufferFactory.allocateBuffer(prefix.length).write(prefix)); + } + buffers.add(encodeValue(value, bufferFactory, valueType, mimeType, hints)); + if (suffix.length > 0) { + buffers.add(bufferFactory.allocateBuffer(suffix.length).write(suffix)); + } + return bufferFactory.join(buffers); + } + + protected Flux encodeNonStream(Publisher inputStream, DataBufferFactory bufferFactory, + ResolvableType elementType, @Nullable MimeType mimeType, @Nullable Map hints) { ResolvableType listType = ResolvableType.forClassWithGenerics(List.class, elementType); return Flux.from(inputStream) @@ -109,7 +132,6 @@ public Flux encode(Publisher inputStream, DataBufferFactory buffe .flux(); } - @Override public DataBuffer encodeValue(Object value, DataBufferFactory bufferFactory, ResolvableType valueType, @Nullable MimeType mimeType, diff --git a/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationSupport.java b/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationSupport.java index 792b2b5abc1c..b3212239cb78 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationSupport.java +++ b/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationSupport.java @@ -16,6 +16,7 @@ package org.springframework.http.codec; +import java.lang.reflect.Method; import java.lang.reflect.Type; import java.util.Arrays; import java.util.HashSet; @@ -23,14 +24,21 @@ import java.util.Map; import java.util.Set; +import kotlin.reflect.KFunction; +import kotlin.reflect.KType; +import kotlin.reflect.full.KCallables; +import kotlin.reflect.jvm.ReflectJvmMapping; import kotlinx.serialization.KSerializer; import kotlinx.serialization.SerialFormat; import kotlinx.serialization.SerializersKt; import kotlinx.serialization.descriptors.PolymorphicKind; import kotlinx.serialization.descriptors.SerialDescriptor; +import org.springframework.core.KotlinDetector; +import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.MimeType; @@ -46,7 +54,10 @@ */ public abstract class KotlinSerializationSupport { - private final Map> serializerCache = new ConcurrentReferenceHashMap<>(); + private final Map> typeSerializerCache = new ConcurrentReferenceHashMap<>(); + + private final Map> kTypeSerializerCache = new ConcurrentReferenceHashMap<>(); + private final T format; @@ -117,8 +128,34 @@ private boolean supports(@Nullable MimeType mimeType) { */ @Nullable protected final KSerializer serializer(ResolvableType resolvableType) { + if (resolvableType.getSource() instanceof MethodParameter parameter) { + Method method = parameter.getMethod(); + Assert.notNull(method, "Method must not be null"); + if (KotlinDetector.isKotlinType(method.getDeclaringClass())) { + KFunction function = ReflectJvmMapping.getKotlinFunction(method); + if (function != null) { + KType type = (parameter.getParameterIndex() == -1 ? function.getReturnType() : + KCallables.getValueParameters(function).get(parameter.getParameterIndex()).getType()); + KSerializer serializer = this.kTypeSerializerCache.get(type); + if (serializer == null) { + try { + serializer = SerializersKt.serializerOrNull(this.format.getSerializersModule(), type); + } + catch (IllegalArgumentException ignored) { + } + if (serializer != null) { + if (hasPolymorphism(serializer.getDescriptor(), new HashSet<>())) { + return null; + } + this.kTypeSerializerCache.put(type, serializer); + } + } + return serializer; + } + } + } Type type = resolvableType.getType(); - KSerializer serializer = this.serializerCache.get(type); + KSerializer serializer = this.typeSerializerCache.get(type); if (serializer == null) { try { serializer = SerializersKt.serializerOrNull(this.format.getSerializersModule(), type); @@ -129,7 +166,7 @@ protected final KSerializer serializer(ResolvableType resolvableType) { if (hasPolymorphism(serializer.getDescriptor(), new HashSet<>())) { return null; } - this.serializerCache.put(type, serializer); + this.typeSerializerCache.put(type, serializer); } } return serializer; diff --git a/spring-web/src/main/java/org/springframework/http/codec/ResourceHttpMessageWriter.java b/spring-web/src/main/java/org/springframework/http/codec/ResourceHttpMessageWriter.java index f1517acfda00..bbcc49b9be50 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/ResourceHttpMessageWriter.java +++ b/spring-web/src/main/java/org/springframework/http/codec/ResourceHttpMessageWriter.java @@ -135,17 +135,6 @@ private Mono writeResource(Resource resource, ResolvableType type, @Nullab })); } - /** - * Adds the default headers for the given resource to the given message. - * @since 6.0 - * @deprecated since 6.1, in favor of {@link #addDefaultHeaders(ReactiveHttpOutputMessage, Resource, MediaType, Map)}, - * for removal = 6.2 - */ - @Deprecated(since = "6.1", forRemoval = true) - public void addHeaders(ReactiveHttpOutputMessage message, Resource resource, @Nullable MediaType contentType, Map hints) { - addDefaultHeaders(message, resource, contentType, hints).block(); - } - /** * Adds the default headers for the given resource to the given message. * @since 6.1 diff --git a/spring-web/src/main/java/org/springframework/http/codec/ServerCodecConfigurer.java b/spring-web/src/main/java/org/springframework/http/codec/ServerCodecConfigurer.java index 864ea6dc1121..84134dce90b9 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/ServerCodecConfigurer.java +++ b/spring-web/src/main/java/org/springframework/http/codec/ServerCodecConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +63,7 @@ public interface ServerCodecConfigurer extends CodecConfigurer { ServerDefaultCodecs defaultCodecs(); /** - * {@inheritDoc}. + * {@inheritDoc} */ @Override ServerCodecConfigurer clone(); diff --git a/spring-web/src/main/java/org/springframework/http/codec/ServerSentEvent.java b/spring-web/src/main/java/org/springframework/http/codec/ServerSentEvent.java index af85c2c6a7b4..397524427898 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/ServerSentEvent.java +++ b/spring-web/src/main/java/org/springframework/http/codec/ServerSentEvent.java @@ -19,6 +19,8 @@ import java.time.Duration; import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; /** * Representation for a Server-Sent Event for use with Spring's reactive Web support. @@ -101,6 +103,49 @@ public T data() { return this.data; } + /** + * Return a StringBuilder with the id, event, retry, and comment fields fully + * serialized, and also appending "data:" if there is data. + * @since 6.2.1 + */ + public String format() { + StringBuilder sb = new StringBuilder(); + if (this.id != null) { + appendAttribute("id", this.id, sb); + } + if (this.event != null) { + appendAttribute("event", this.event, sb); + } + if (this.retry != null) { + appendAttribute("retry", this.retry.toMillis(), sb); + } + if (this.comment != null) { + sb.append(':').append(StringUtils.replace(this.comment, "\n", "\n:")).append('\n'); + } + if (this.data != null) { + sb.append("data:"); + } + return sb.toString(); + } + + private void appendAttribute(String fieldName, Object fieldValue, StringBuilder sb) { + sb.append(fieldName).append(':').append(fieldValue).append('\n'); + } + + @Override + public boolean equals(@Nullable Object other) { + return (this == other || (other instanceof ServerSentEvent that && + ObjectUtils.nullSafeEquals(this.id, that.id) && + ObjectUtils.nullSafeEquals(this.event, that.event) && + ObjectUtils.nullSafeEquals(this.retry, that.retry) && + ObjectUtils.nullSafeEquals(this.comment, that.comment) && + ObjectUtils.nullSafeEquals(this.data, that.data))); + } + + @Override + public int hashCode() { + return ObjectUtils.nullSafeHash(this.id, this.event, this.retry, this.comment, this.data); + } @Override public String toString() { diff --git a/spring-web/src/main/java/org/springframework/http/codec/ServerSentEventHttpMessageReader.java b/spring-web/src/main/java/org/springframework/http/codec/ServerSentEventHttpMessageReader.java index f012b9283978..61289b03e927 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/ServerSentEventHttpMessageReader.java +++ b/spring-web/src/main/java/org/springframework/http/codec/ServerSentEventHttpMessageReader.java @@ -138,6 +138,7 @@ public Flux read( } @Nullable + @SuppressWarnings("NullAway") private Object buildEvent(List lines, ResolvableType valueType, boolean shouldWrap, Map hints) { 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 e23937943a94..28aac85286a9 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-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +17,6 @@ package org.springframework.http.codec; import java.nio.charset.StandardCharsets; -import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Map; @@ -124,38 +123,19 @@ private Flux> encode(Publisher input, ResolvableType el ServerSentEvent sse = (element instanceof ServerSentEvent serverSentEvent ? serverSentEvent : ServerSentEvent.builder().data(element).build()); - StringBuilder sb = new StringBuilder(); - String id = sse.id(); - String event = sse.event(); - Duration retry = sse.retry(); - String comment = sse.comment(); + String sseText = sse.format(); Object data = sse.data(); - if (id != null) { - writeField("id", id, sb); - } - if (event != null) { - writeField("event", event, sb); - } - if (retry != null) { - writeField("retry", retry.toMillis(), sb); - } - if (comment != null) { - sb.append(':').append(StringUtils.replace(comment, "\n", "\n:")).append('\n'); - } - if (data != null) { - sb.append("data:"); - } Flux result; if (data == null) { - result = Flux.just(encodeText(sb + "\n", mediaType, factory)); + result = Flux.just(encodeText(sseText + "\n", mediaType, factory)); } else if (data instanceof String text) { text = StringUtils.replace(text, "\n", "\ndata:"); - result = Flux.just(encodeText(sb + text + "\n\n", mediaType, factory)); + result = Flux.just(encodeText(sseText + text + "\n\n", mediaType, factory)); } else { - result = encodeEvent(sb, data, dataType, mediaType, factory, hints); + result = encodeEvent(sseText, data, dataType, mediaType, factory, hints); } return result.doOnDiscard(DataBuffer.class, DataBufferUtils::release); @@ -163,7 +143,7 @@ else if (data instanceof String text) { } @SuppressWarnings("unchecked") - private Flux encodeEvent(StringBuilder eventContent, T data, ResolvableType dataType, + private Flux encodeEvent(CharSequence sseText, T data, ResolvableType dataType, MediaType mediaType, DataBufferFactory factory, Map hints) { if (this.encoder == null) { @@ -171,7 +151,7 @@ private Flux encodeEvent(StringBuilder eventContent, T data, Res } return Flux.defer(() -> { - DataBuffer startBuffer = encodeText(eventContent, mediaType, factory); + DataBuffer startBuffer = encodeText(sseText, 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); @@ -179,10 +159,6 @@ private Flux encodeEvent(StringBuilder eventContent, T data, Res }); } - private void writeField(String fieldName, Object fieldValue, StringBuilder sb) { - sb.append(fieldName).append(':').append(fieldValue).append('\n'); - } - private DataBuffer encodeText(CharSequence text, MediaType mediaType, DataBufferFactory bufferFactory) { Assert.notNull(mediaType.getCharset(), "Expected MediaType with charset"); byte[] bytes = text.toString().getBytes(mediaType.getCharset()); diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/AbstractJackson2Decoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/AbstractJackson2Decoder.java index 9980996db8f6..03e5ea2c17e3 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/AbstractJackson2Decoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/AbstractJackson2Decoder.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.lang.annotation.Annotation; import java.math.BigDecimal; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; @@ -96,6 +97,7 @@ public int getMaxInMemorySize() { } + @SuppressWarnings("deprecation") // as of Jackson 2.18: can(De)Serialize @Override public boolean canDecode(ResolvableType elementType, @Nullable MimeType mimeType) { if (!supportsMimeType(mimeType)) { @@ -136,9 +138,12 @@ public Flux decode(Publisher input, ResolvableType elementTy forceUseOfBigDecimal = true; } + boolean tokenizeArrays = (!elementType.isArray() && + !Collection.class.isAssignableFrom(elementType.resolve(Object.class))); + Flux processed = processInput(input, elementType, mimeType, hints); Flux tokens = Jackson2Tokenizer.tokenize(processed, mapper.getFactory(), mapper, - true, forceUseOfBigDecimal, getMaxInMemorySize()); + tokenizeArrays, forceUseOfBigDecimal, getMaxInMemorySize()); return Flux.deferContextual(contextView -> { diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/AbstractJackson2Encoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/AbstractJackson2Encoder.java index 78e20160efe2..7965a651f9bb 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/AbstractJackson2Encoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/AbstractJackson2Encoder.java @@ -105,6 +105,7 @@ public void setStreamingMediaTypes(List mediaTypes) { } + @SuppressWarnings("deprecation") // as of Jackson 2.18: can(De)Serialize @Override public boolean canEncode(ResolvableType elementType, @Nullable MimeType mimeType) { if (!supportsMimeType(mimeType)) { diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java index 8c6ea40efd79..2e52df2ccb8c 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonEncoder.java index 897fbaccfd45..92cd20e3b8c4 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonEncoder.java @@ -17,11 +17,20 @@ package org.springframework.http.codec.json; import java.util.List; +import java.util.Map; import kotlinx.serialization.json.Json; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.http.MediaType; import org.springframework.http.codec.KotlinSerializationStringEncoder; +import org.springframework.lang.Nullable; +import org.springframework.util.MimeType; /** * Encode from an {@code Object} stream to a byte stream of JSON objects using @@ -49,4 +58,38 @@ public KotlinSerializationJsonEncoder(Json json) { setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON)); } + @Override + public Flux encodeNonStream(Publisher inputStream, DataBufferFactory bufferFactory, + ResolvableType elementType, @Nullable MimeType mimeType, @Nullable Map hints) { + + JsonArrayJoinHelper helper = new JsonArrayJoinHelper(); + return Flux.from(inputStream) + .map(value -> encodeStreamingValue(value, bufferFactory, elementType, mimeType, hints, + helper.getPrefix(), EMPTY_BYTES)) + .switchIfEmpty(Mono.fromCallable(() -> bufferFactory.wrap(helper.getPrefix()))) + .concatWith(Mono.fromCallable(() -> bufferFactory.wrap(helper.getSuffix()))); + } + + + private static class JsonArrayJoinHelper { + + private static final byte[] COMMA_SEPARATOR = {','}; + + private static final byte[] OPEN_BRACKET = {'['}; + + private static final byte[] CLOSE_BRACKET = {']'}; + + private boolean firstItemEmitted; + + public byte[] getPrefix() { + byte[] prefix = (this.firstItemEmitted ? COMMA_SEPARATOR : OPEN_BRACKET); + this.firstItemEmitted = true; + return prefix; + } + + public byte[] getSuffix() { + return CLOSE_BRACKET; + } + } + } 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 4e1bc3068b69..eecf24215a5b 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-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -66,7 +66,8 @@ public class DefaultPartHttpMessageReader extends LoggingCodecSupport implements private int maxParts = -1; - private Scheduler blockingOperationScheduler = Schedulers.boundedElastic(); + @Nullable + private Scheduler blockingOperationScheduler; private FileStorage fileStorage = FileStorage.tempDirectory(this::getBlockingOperationScheduler); @@ -152,7 +153,8 @@ public void setBlockingOperationScheduler(Scheduler blockingOperationScheduler) } private Scheduler getBlockingOperationScheduler() { - return this.blockingOperationScheduler; + return (this.blockingOperationScheduler != null ? + this.blockingOperationScheduler : Schedulers.boundedElastic()); } /** @@ -206,7 +208,7 @@ public Flux read(ResolvableType elementType, ReactiveHttpInputMessage mess else { return PartGenerator.createPart(partsTokens, this.maxInMemorySize, this.maxDiskUsagePerPart, - this.fileStorage.directory(), this.blockingOperationScheduler); + this.fileStorage.directory(), getBlockingOperationScheduler()); } }); }); diff --git a/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriter.java b/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriter.java index eeb1c5909346..2ca17063e56d 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriter.java +++ b/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -283,7 +283,7 @@ else if (resolvableType.resolve() == Resource.class) { .filter(partWriter -> partWriter.canWrite(finalBodyType, contentType)) .findFirst(); - if (!writer.isPresent()) { + if (writer.isEmpty()) { return Flux.error(new CodecException("No suitable writer found for part: " + name)); } 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 807b696f9b98..789a3726e470 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 @@ -49,6 +49,7 @@ * @author Arjen Poutsma * @since 5.3 */ +@SuppressWarnings("NullAway") final class MultipartParser extends BaseSubscriber { private static final byte CR = '\r'; @@ -99,7 +100,6 @@ public static Flux parse(Flux buffers, byte[] boundary, int m return Flux.create(sink -> { MultipartParser parser = new MultipartParser(sink, boundary, maxHeadersSize, headersCharset); sink.onCancel(parser::onSinkCancel); - sink.onRequest(n -> parser.requestBuffer()); buffers.subscribe(parser); }); } @@ -111,7 +111,9 @@ public Context currentContext() { @Override protected void hookOnSubscribe(Subscription subscription) { - requestBuffer(); + if (this.sink.requestedFromDownstream() > 0) { + requestBuffer(); + } } @Override diff --git a/spring-web/src/main/java/org/springframework/http/codec/multipart/Part.java b/spring-web/src/main/java/org/springframework/http/codec/multipart/Part.java index 89e6db530358..3b36dd974976 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/multipart/Part.java +++ b/spring-web/src/main/java/org/springframework/http/codec/multipart/Part.java @@ -29,7 +29,7 @@ * part is either a {@link FormFieldPart} or a {@link FilePart}. * *

        Multipart requests may also be used outside a browser for data of any - * content type (e.g. JSON, PDF, etc). + * content type (for example, JSON, PDF, etc). * * @author Sebastien Deleuze * @author Rossen Stoyanchev diff --git a/spring-web/src/main/java/org/springframework/http/codec/multipart/PartEventHttpMessageReader.java b/spring-web/src/main/java/org/springframework/http/codec/multipart/PartEventHttpMessageReader.java index 9f54c4727d90..68dc17e57fb7 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/multipart/PartEventHttpMessageReader.java +++ b/spring-web/src/main/java/org/springframework/http/codec/multipart/PartEventHttpMessageReader.java @@ -38,6 +38,7 @@ import org.springframework.http.ReactiveHttpInputMessage; import org.springframework.http.codec.HttpMessageReader; import org.springframework.http.codec.LoggingCodecSupport; +import org.springframework.http.codec.multipart.MultipartParser.HeadersToken; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -154,7 +155,7 @@ public Flux read(ResolvableType elementType, ReactiveHttpInputMessage AtomicInteger partCount = new AtomicInteger(); return allPartsTokens - .windowUntil(t -> t instanceof MultipartParser.HeadersToken, true) + .windowUntil(HeadersToken.class::isInstance, true) .concatMap(partTokens -> partTokens .switchOnFirst((signal, flux) -> { if (!signal.hasValue()) { 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 6519b8c7b0d0..cb7b94b8bdb1 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 @@ -57,6 +57,7 @@ * @author Arjen Poutsma * @since 5.3 */ +@SuppressWarnings("NullAway") final class PartGenerator extends BaseSubscriber { private static final Log logger = LogFactory.getLog(PartGenerator.class); diff --git a/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufEncoder.java index 39a5c94a689e..43e9dd9f4330 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufEncoder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -47,7 +47,7 @@ * *

        To generate {@code Message} Java classes, you need to install the {@code protoc} binary. * - *

        This encoder requires Protobuf 3 or higher, and supports + *

        This encoder requires Protobuf 3.29 or higher, and supports * {@code "application/x-protobuf"} and {@code "application/octet-stream"} with the official * {@code "com.google.protobuf:protobuf-java"} library. * diff --git a/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufHttpMessageWriter.java b/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufHttpMessageWriter.java index 8a9bddcd332c..6f9e4c308164 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufHttpMessageWriter.java +++ b/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufHttpMessageWriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2024 the original author 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,8 +28,8 @@ import reactor.core.publisher.Mono; import org.springframework.core.ResolvableType; -import org.springframework.core.codec.DecodingException; import org.springframework.core.codec.Encoder; +import org.springframework.core.codec.EncodingException; import org.springframework.http.MediaType; import org.springframework.http.ReactiveHttpOutputMessage; import org.springframework.http.codec.EncoderHttpMessageWriter; @@ -97,7 +97,7 @@ else if (!ProtobufEncoder.DELIMITED_VALUE.equals(mediaType.getParameters().get(P return super.write(inputStream, elementType, mediaType, message, hints); } catch (Exception ex) { - return Mono.error(new DecodingException("Could not read Protobuf message: " + ex.getMessage(), ex)); + return Mono.error(new EncodingException("Could not write Protobuf message: " + ex.getMessage(), ex)); } } diff --git a/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufJsonDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufJsonDecoder.java new file mode 100644 index 000000000000..d9bdb0b61ed1 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufJsonDecoder.java @@ -0,0 +1,173 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.codec.protobuf; + +import java.io.InputStreamReader; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentMap; + +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.DecodingException; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferLimitException; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.MimeType; + +/** + * A {@code Decoder} that reads a JSON byte stream and converts it to + * Google Protocol Buffers + * {@link com.google.protobuf.Message}s. + * + *

        Flux deserialized via + * {@link #decode(Publisher, ResolvableType, MimeType, Map)} are not supported because + * the Protobuf Java Util library does not provide a non-blocking parser + * that splits a JSON stream into tokens. + * Applications should consider decoding to {@code Mono} or + * {@code Mono>}, which will use the supported + * {@link #decodeToMono(Publisher, ResolvableType, MimeType, Map)}. + * + *

        To generate {@code Message} Java classes, you need to install the + * {@code protoc} binary. + * + *

        This decoder requires Protobuf 3.29 or higher, and supports + * {@code "application/json"} and {@code "application/*+json"} with + * the official {@code "com.google.protobuf:protobuf-java-util"} library. + * + * @author Brian Clozel + * @since 6.2 + * @see ProtobufJsonEncoder + */ +public class ProtobufJsonDecoder implements Decoder { + + /** The default max size for aggregating messages. */ + protected static final int DEFAULT_MESSAGE_MAX_SIZE = 256 * 1024; + + private static final List defaultMimeTypes = List.of(MediaType.APPLICATION_JSON, + new MediaType("application", "*+json")); + + private static final ConcurrentMap, Method> methodCache = new ConcurrentReferenceHashMap<>(); + + private final JsonFormat.Parser parser; + + private int maxMessageSize = DEFAULT_MESSAGE_MAX_SIZE; + + /** + * Construct a new {@link ProtobufJsonDecoder} using a default {@link JsonFormat.Parser} instance. + */ + public ProtobufJsonDecoder() { + this(JsonFormat.parser()); + } + + /** + * Construct a new {@link ProtobufJsonDecoder} using the given {@link JsonFormat.Parser} instance. + */ + public ProtobufJsonDecoder(JsonFormat.Parser parser) { + this.parser = parser; + } + + /** + * Return the {@link #setMaxMessageSize configured} message size limit. + */ + public int getMaxMessageSize() { + return this.maxMessageSize; + } + + /** + * The max size allowed per message. + *

        By default, this is set to 256K. + * @param maxMessageSize the max size per message, or -1 for unlimited + */ + public void setMaxMessageSize(int maxMessageSize) { + this.maxMessageSize = maxMessageSize; + } + + @Override + public boolean canDecode(ResolvableType elementType, @Nullable MimeType mimeType) { + return Message.class.isAssignableFrom(elementType.toClass()) && supportsMimeType(mimeType); + } + + private static boolean supportsMimeType(@Nullable MimeType mimeType) { + if (mimeType == null) { + return false; + } + for (MimeType m : defaultMimeTypes) { + if (m.isCompatibleWith(mimeType)) { + return true; + } + } + return false; + } + + + @Override + public List getDecodableMimeTypes() { + return defaultMimeTypes; + } + + @Override + public Flux decode(Publisher inputStream, ResolvableType targetType, @Nullable MimeType mimeType, @Nullable Map hints) { + return Flux.error(new UnsupportedOperationException("Protobuf decoder does not support Flux, use Mono> instead.")); + } + + @Override + public Message decode(DataBuffer dataBuffer, ResolvableType targetType, @Nullable MimeType mimeType, @Nullable Map hints) throws DecodingException { + try { + Message.Builder builder = getMessageBuilder(targetType.toClass()); + this.parser.merge(new InputStreamReader(dataBuffer.asInputStream()), builder); + return builder.build(); + } + catch (Exception ex) { + throw new DecodingException("Could not read Protobuf message: " + ex.getMessage(), ex); + } + finally { + DataBufferUtils.release(dataBuffer); + } + } + + /** + * Create a new {@code Message.Builder} instance for the given class. + *

        This method uses a ConcurrentHashMap for caching method lookups. + */ + private static Message.Builder getMessageBuilder(Class clazz) throws Exception { + Method method = methodCache.get(clazz); + if (method == null) { + method = clazz.getMethod("newBuilder"); + methodCache.put(clazz, method); + } + return (Message.Builder) method.invoke(clazz); + } + + @Override + public Mono decodeToMono(Publisher inputStream, ResolvableType elementType, @Nullable MimeType mimeType, @Nullable Map hints) { + return DataBufferUtils.join(inputStream, this.maxMessageSize) + .map(dataBuffer -> decode(dataBuffer, elementType, mimeType, hints)) + .onErrorMap(DataBufferLimitException.class, exc -> new DecodingException("Could not decode JSON as Protobuf message", exc)); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoder.java new file mode 100644 index 000000000000..2b836ea60aac --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoder.java @@ -0,0 +1,173 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.codec.protobuf; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.MediaType; +import org.springframework.http.codec.HttpMessageEncoder; +import org.springframework.lang.Nullable; +import org.springframework.util.FastByteArrayOutputStream; +import org.springframework.util.MimeType; + +/** + * A {@code Encoder} that writes {@link com.google.protobuf.Message}s as JSON. + * + *

        To generate {@code Message} Java classes, you need to install the + * {@code protoc} binary. + * + *

        This encoder requires Protobuf 3.29 or higher, and supports + * {@code "application/json"} and {@code "application/*+json"} with + * the official {@code "com.google.protobuf:protobuf-java-util"} library. + * + * @author Brian Clozel + * @since 6.2 + * @see ProtobufJsonDecoder + */ +public class ProtobufJsonEncoder implements HttpMessageEncoder { + + private static final byte[] EMPTY_BYTES = new byte[0]; + + private static final ResolvableType MESSAGE_TYPE = ResolvableType.forClass(Message.class); + + private static final List defaultMimeTypes = List.of( + MediaType.APPLICATION_JSON, + new MediaType("application", "*+json")); + + private final JsonFormat.Printer printer; + + + /** + * Construct a new {@link ProtobufJsonEncoder} using a default {@link JsonFormat.Printer} instance. + */ + public ProtobufJsonEncoder() { + this(JsonFormat.printer()); + } + + /** + * Construct a new {@link ProtobufJsonEncoder} using the given {@link JsonFormat.Printer} instance. + */ + public ProtobufJsonEncoder(JsonFormat.Printer printer) { + this.printer = printer; + } + + @Override + public List getStreamingMediaTypes() { + return List.of(MediaType.APPLICATION_NDJSON); + } + + @Override + public List getEncodableMimeTypes() { + return defaultMimeTypes; + } + + @Override + public boolean canEncode(ResolvableType elementType, @Nullable MimeType mimeType) { + return Message.class.isAssignableFrom(elementType.toClass()) && supportsMimeType(mimeType); + } + + private static boolean supportsMimeType(@Nullable MimeType mimeType) { + if (mimeType == null) { + return false; + } + for (MimeType m : defaultMimeTypes) { + if (m.isCompatibleWith(mimeType)) { + return true; + } + } + return false; + } + + @Override + public Flux encode(Publisher inputStream, DataBufferFactory bufferFactory, ResolvableType elementType, @Nullable MimeType mimeType, @Nullable Map hints) { + if (inputStream instanceof Mono) { + return Mono.from(inputStream) + .map(value -> encodeValue(value, bufferFactory, elementType, mimeType, hints)) + .flux(); + } + JsonArrayJoinHelper helper = new JsonArrayJoinHelper(); + + // Do not prepend JSON array prefix until first signal is known, onNext vs onError + // Keeps response not committed for error handling + return Flux.from(inputStream) + .map(value -> { + byte[] prefix = helper.getPrefix(); + byte[] delimiter = helper.getDelimiter(); + DataBuffer dataBuffer = encodeValue(value, bufferFactory, MESSAGE_TYPE, mimeType, hints); + return (prefix.length > 0 ? + bufferFactory.join(List.of(bufferFactory.wrap(prefix), bufferFactory.wrap(delimiter), dataBuffer)) : + bufferFactory.join(List.of(bufferFactory.wrap(delimiter), dataBuffer))); + }) + .switchIfEmpty(Mono.fromCallable(() -> bufferFactory.wrap(helper.getPrefix()))) + .concatWith(Mono.fromCallable(() -> bufferFactory.wrap(helper.getSuffix()))); + } + + @Override + public DataBuffer encodeValue(Message message, DataBufferFactory bufferFactory, ResolvableType valueType, @Nullable MimeType mimeType, @Nullable Map hints) { + FastByteArrayOutputStream bos = new FastByteArrayOutputStream(); + OutputStreamWriter writer = new OutputStreamWriter(bos, StandardCharsets.UTF_8); + try { + this.printer.appendTo(message, writer); + writer.flush(); + byte[] bytes = bos.toByteArrayUnsafe(); + return bufferFactory.wrap(bytes); + } + catch (IOException ex) { + throw new IllegalStateException("Unexpected I/O error while writing to data buffer", ex); + } + } + + private static class JsonArrayJoinHelper { + + private static final byte[] COMMA_SEPARATOR = {','}; + + private static final byte[] OPEN_BRACKET = {'['}; + + private static final byte[] CLOSE_BRACKET = {']'}; + + private boolean firstItemEmitted; + + public byte[] getDelimiter() { + if (this.firstItemEmitted) { + return COMMA_SEPARATOR; + } + this.firstItemEmitted = true; + return EMPTY_BYTES; + } + + public byte[] getPrefix() { + return (this.firstItemEmitted ? EMPTY_BYTES : OPEN_BRACKET); + } + + public byte[] getSuffix() { + return CLOSE_BRACKET; + } + } +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/AbstractGenericHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/AbstractGenericHttpMessageConverter.java index be025f95c803..e6f31e8d6b53 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/AbstractGenericHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/AbstractGenericHttpMessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.io.OutputStream; import java.lang.reflect.Type; +import java.nio.charset.Charset; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpOutputMessage; @@ -60,6 +61,17 @@ protected AbstractGenericHttpMessageConverter(MediaType... supportedMediaTypes) super(supportedMediaTypes); } + /** + * Construct an {@code AbstractGenericHttpMessageConverter} with a default charset and + * multiple supported media types. + * @param defaultCharset the default character set + * @param supportedMediaTypes the supported media types + * @since 6.2 + */ + protected AbstractGenericHttpMessageConverter(Charset defaultCharset, MediaType... supportedMediaTypes) { + super(defaultCharset, supportedMediaTypes); + } + @Override protected boolean supports(Class clazz) { diff --git a/spring-web/src/main/java/org/springframework/http/converter/AbstractKotlinSerializationHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/AbstractKotlinSerializationHttpMessageConverter.java index 0c73ecc01a02..e429ae5fe030 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/AbstractKotlinSerializationHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/AbstractKotlinSerializationHttpMessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,22 +17,30 @@ package org.springframework.http.converter; import java.io.IOException; +import java.lang.reflect.Method; import java.lang.reflect.Type; import java.util.HashSet; import java.util.Map; import java.util.Set; +import kotlin.reflect.KFunction; +import kotlin.reflect.KType; +import kotlin.reflect.full.KCallables; +import kotlin.reflect.jvm.ReflectJvmMapping; import kotlinx.serialization.KSerializer; import kotlinx.serialization.SerialFormat; import kotlinx.serialization.SerializersKt; import kotlinx.serialization.descriptors.PolymorphicKind; import kotlinx.serialization.descriptors.SerialDescriptor; -import org.springframework.core.GenericTypeResolver; +import org.springframework.core.KotlinDetector; +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpOutputMessage; import org.springframework.http.MediaType; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.util.ConcurrentReferenceHashMap; @@ -48,9 +56,11 @@ * @since 6.0 * @param the type of {@link SerialFormat} */ -public abstract class AbstractKotlinSerializationHttpMessageConverter extends AbstractGenericHttpMessageConverter { +public abstract class AbstractKotlinSerializationHttpMessageConverter extends AbstractSmartHttpMessageConverter { - private final Map> serializerCache = new ConcurrentReferenceHashMap<>(); + private final Map> kTypeSerializerCache = new ConcurrentReferenceHashMap<>(); + + private final Map> typeSerializerCache = new ConcurrentReferenceHashMap<>(); private final T format; @@ -66,15 +76,14 @@ protected AbstractKotlinSerializationHttpMessageConverter(T format, MediaType... this.format = format; } - @Override protected boolean supports(Class clazz) { - return serializer(clazz) != null; + return serializer(ResolvableType.forClass(clazz)) != null; } @Override - public boolean canRead(Type type, @Nullable Class contextClass, @Nullable MediaType mediaType) { - if (serializer(GenericTypeResolver.resolveType(type, contextClass)) != null) { + public boolean canRead(ResolvableType type, @Nullable MediaType mediaType) { + if (!ResolvableType.NONE.equals(type) && serializer(type) != null) { return canRead(mediaType); } else { @@ -83,8 +92,8 @@ public boolean canRead(Type type, @Nullable Class contextClass, @Nullable Med } @Override - public boolean canWrite(@Nullable Type type, Class clazz, @Nullable MediaType mediaType) { - if (serializer(type != null ? GenericTypeResolver.resolveType(type, clazz) : clazz) != null) { + public boolean canWrite(ResolvableType type, Class clazz, @Nullable MediaType mediaType) { + if (!ResolvableType.NONE.equals(type) && serializer(type) != null) { return canWrite(mediaType); } else { @@ -93,24 +102,12 @@ public boolean canWrite(@Nullable Type type, Class clazz, @Nullable MediaType } @Override - public final Object read(Type type, @Nullable Class contextClass, HttpInputMessage inputMessage) + public final Object read(ResolvableType type, HttpInputMessage inputMessage, @Nullable Map hints) throws IOException, HttpMessageNotReadableException { - Type resolvedType = GenericTypeResolver.resolveType(type, contextClass); - KSerializer serializer = serializer(resolvedType); + KSerializer serializer = serializer(type); if (serializer == null) { - throw new HttpMessageNotReadableException("Could not find KSerializer for " + resolvedType, inputMessage); - } - return readInternal(serializer, this.format, inputMessage); - } - - @Override - protected final Object readInternal(Class clazz, HttpInputMessage inputMessage) - throws IOException, HttpMessageNotReadableException { - - KSerializer serializer = serializer(clazz); - if (serializer == null) { - throw new HttpMessageNotReadableException("Could not find KSerializer for " + clazz, inputMessage); + throw new HttpMessageNotReadableException("Could not find KSerializer for " + type, inputMessage); } return readInternal(serializer, this.format, inputMessage); } @@ -122,13 +119,13 @@ protected abstract Object readInternal(KSerializer serializer, T format, throws IOException, HttpMessageNotReadableException; @Override - protected final void writeInternal(Object object, @Nullable Type type, HttpOutputMessage outputMessage) - throws IOException, HttpMessageNotWritableException { + protected final void writeInternal(Object object, ResolvableType type, HttpOutputMessage outputMessage, + @Nullable Map hints) throws IOException, HttpMessageNotWritableException { - Type resolvedType = type != null ? type : object.getClass(); - KSerializer serializer = serializer(resolvedType); + ResolvableType resolvableType = (ResolvableType.NONE.equals(type) ? ResolvableType.forInstance(object) : type); + KSerializer serializer = serializer(resolvableType); if (serializer == null) { - throw new HttpMessageNotWritableException("Could not find KSerializer for " + resolvedType); + throw new HttpMessageNotWritableException("Could not find KSerializer for " + resolvableType); } writeInternal(object, serializer, this.format, outputMessage); } @@ -143,12 +140,39 @@ protected abstract void writeInternal(Object object, KSerializer seriali * Tries to find a serializer that can marshall or unmarshall instances of the given type * using kotlinx.serialization. If no serializer can be found, {@code null} is returned. *

        Resolved serializers are cached and cached results are returned on successive calls. - * @param type the type to find a serializer for + * @param resolvableType the type to find a serializer for * @return a resolved serializer for the given type, or {@code null} */ @Nullable - private KSerializer serializer(Type type) { - KSerializer serializer = this.serializerCache.get(type); + private KSerializer serializer(ResolvableType resolvableType) { + if (resolvableType.getSource() instanceof MethodParameter parameter) { + Method method = parameter.getMethod(); + Assert.notNull(method, "Method must not be null"); + if (KotlinDetector.isKotlinType(method.getDeclaringClass())) { + KFunction function = ReflectJvmMapping.getKotlinFunction(method); + if (function != null) { + KType type = (parameter.getParameterIndex() == -1 ? function.getReturnType() : + KCallables.getValueParameters(function).get(parameter.getParameterIndex()).getType()); + KSerializer serializer = this.kTypeSerializerCache.get(type); + if (serializer == null) { + try { + serializer = SerializersKt.serializerOrNull(this.format.getSerializersModule(), type); + } + catch (IllegalArgumentException ignored) { + } + if (serializer != null) { + if (hasPolymorphism(serializer.getDescriptor(), new HashSet<>())) { + return null; + } + this.kTypeSerializerCache.put(type, serializer); + } + } + return serializer; + } + } + } + Type type = resolvableType.getType(); + KSerializer serializer = this.typeSerializerCache.get(type); if (serializer == null) { try { serializer = SerializersKt.serializerOrNull(this.format.getSerializersModule(), type); @@ -159,7 +183,7 @@ private KSerializer serializer(Type type) { if (hasPolymorphism(serializer.getDescriptor(), new HashSet<>())) { return null; } - this.serializerCache.put(type, serializer); + this.typeSerializerCache.put(type, serializer); } } return serializer; diff --git a/spring-web/src/main/java/org/springframework/http/converter/AbstractSmartHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/AbstractSmartHttpMessageConverter.java new file mode 100644 index 000000000000..bcd9494ac7b7 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/AbstractSmartHttpMessageConverter.java @@ -0,0 +1,148 @@ +/* + * Copyright 2002-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.converter; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Map; + +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.StreamingHttpOutputMessage; +import org.springframework.lang.Nullable; + +/** + * Abstract base class for most {@link SmartHttpMessageConverter} implementations. + * + * @author Sebastien Deleuze + * @since 6.2 + * @param the converted object type + */ +public abstract class AbstractSmartHttpMessageConverter extends AbstractHttpMessageConverter + implements SmartHttpMessageConverter { + + /** + * Construct an {@code AbstractSmartHttpMessageConverter} with no supported media types. + * @see #setSupportedMediaTypes + */ + protected AbstractSmartHttpMessageConverter() { + } + + /** + * Construct an {@code AbstractSmartHttpMessageConverter} with one supported media type. + * @param supportedMediaType the supported media type + */ + protected AbstractSmartHttpMessageConverter(MediaType supportedMediaType) { + super(supportedMediaType); + } + + /** + * Construct an {@code AbstractSmartHttpMessageConverter} with multiple supported media type. + * @param supportedMediaTypes the supported media types + */ + protected AbstractSmartHttpMessageConverter(MediaType... supportedMediaTypes) { + super(supportedMediaTypes); + } + + + @Override + protected boolean supports(Class clazz) { + return true; + } + + @Override + public boolean canRead(ResolvableType type, @Nullable MediaType mediaType) { + Class clazz = type.resolve(); + return (clazz != null ? canRead(clazz, mediaType) : canRead(mediaType)); + } + + @Override + public boolean canWrite(ResolvableType type, Class clazz, @Nullable MediaType mediaType) { + return canWrite(clazz, mediaType); + } + + /** + * This implementation sets the default headers by calling {@link #addDefaultHeaders}, + * and then calls {@link #writeInternal}. + */ + @Override + public final void write(T t, ResolvableType type, @Nullable MediaType contentType, + HttpOutputMessage outputMessage, @Nullable Map hints) + throws IOException, HttpMessageNotWritableException { + + HttpHeaders headers = outputMessage.getHeaders(); + addDefaultHeaders(headers, t, contentType); + + if (outputMessage instanceof StreamingHttpOutputMessage streamingOutputMessage) { + streamingOutputMessage.setBody(new StreamingHttpOutputMessage.Body() { + @Override + public void writeTo(OutputStream outputStream) throws IOException { + writeInternal(t, type, new HttpOutputMessage() { + @Override + public OutputStream getBody() { + return outputStream; + } + + @Override + public HttpHeaders getHeaders() { + return headers; + } + }, hints); + } + + @Override + public boolean repeatable() { + return supportsRepeatableWrites(t); + } + }); + } + else { + writeInternal(t, type, outputMessage, hints); + outputMessage.getBody().flush(); + } + } + + @Override + protected void writeInternal(T t, HttpOutputMessage outputMessage) + throws IOException, HttpMessageNotWritableException { + + writeInternal(t, ResolvableType.NONE, outputMessage, null); + } + + /** + * Abstract template method that writes the actual body. Invoked from + * {@link #write(Object, ResolvableType, MediaType, HttpOutputMessage, Map)}. + * @param t the object to write to the output message + * @param type the type of object to write + * @param outputMessage the HTTP output message to write to + * @param hints additional information about how to encode + * @throws IOException in case of I/O errors + * @throws HttpMessageNotWritableException in case of conversion errors + */ + protected abstract void writeInternal(T t, ResolvableType type, HttpOutputMessage outputMessage, + @Nullable Map hints) throws IOException, HttpMessageNotWritableException; + + @Override + protected T readInternal(Class clazz, HttpInputMessage inputMessage) + throws IOException, HttpMessageNotReadableException { + + return read(ResolvableType.forClass(clazz), inputMessage, null); + } +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java index d7a64f2cf5d0..34062542faa4 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +48,7 @@ /** * Implementation of {@link HttpMessageConverter} to read and write 'normal' HTML - * forms and also to write (but not read) multipart data (e.g. file uploads). + * forms and also to write (but not read) multipart data (for example, file uploads). * *

        In other words, this converter can read and write the * {@code "application/x-www-form-urlencoded"} media type as @@ -60,14 +60,13 @@ *

        Multipart Data

        * *

        By default, {@code "multipart/form-data"} is used as the content type when - * {@linkplain #write writing} multipart data. As of Spring Framework 5.2 it is - * also possible to write multipart data using other multipart subtypes such as - * {@code "multipart/mixed"} and {@code "multipart/related"}, as long as the - * multipart subtype is registered as a {@linkplain #getSupportedMediaTypes - * supported media type} and the desired multipart subtype is specified - * as the content type when {@linkplain #write writing} the multipart data. Note - * that {@code "multipart/mixed"} is registered as a supported media type by - * default. + * {@linkplain #write writing} multipart data. It is also possible to write + * multipart data using other multipart subtypes such as {@code "multipart/mixed"} + * and {@code "multipart/related"}, as long as the multipart subtype is registered + * as a {@linkplain #getSupportedMediaTypes supported media type} and the + * desired multipart subtype is specified as the content type when + * {@linkplain #write writing} the multipart data. Note that {@code "multipart/mixed"} + * is registered as a supported media type by default. * *

        When writing multipart data, this converter uses other * {@link HttpMessageConverter HttpMessageConverters} to write the respective @@ -81,7 +80,7 @@ * {@code "multipart/form-data"} content type. * *

        - * RestTemplate restTemplate = new RestTemplate();
        + * RestClient restClient = RestClient.create();
          * // AllEncompassingFormHttpMessageConverter is configured by default
          *
          * MultiValueMap<String, Object> form = new LinkedMultiValueMap<>();
        @@ -90,7 +89,12 @@
          * form.add("field 2", "value 3");
          * form.add("field 3", 4);  // non-String form values supported as of 5.1.4
          *
        - * restTemplate.postForLocation("https://example.com/myForm", form);
        + * ResponseEntity<Void> response = restClient.post() + * .uri("https://example.com/myForm") + * .contentType(MULTIPART_FORM_DATA) + * .body(form) + * .retrieve() + * .toBodilessEntity(); * *

        The following snippet shows how to do a file upload using the * {@code "multipart/form-data"} content type. @@ -100,7 +104,12 @@ * parts.add("field 1", "value 1"); * parts.add("file", new ClassPathResource("myFile.jpg")); * - * restTemplate.postForLocation("https://example.com/myFileUpload", parts); + * ResponseEntity<Void> response = restClient.post() + * .uri("https://example.com/myForm") + * .contentType(MULTIPART_FORM_DATA) + * .body(parts) + * .retrieve() + * .toBodilessEntity(); * *

        The following snippet shows how to do a file upload using the * {@code "multipart/mixed"} content type. @@ -110,34 +119,35 @@ * parts.add("field 1", "value 1"); * parts.add("file", new ClassPathResource("myFile.jpg")); * - * HttpHeaders requestHeaders = new HttpHeaders(); - * requestHeaders.setContentType(MediaType.MULTIPART_MIXED); - * - * restTemplate.postForLocation("https://example.com/myFileUpload", - * new HttpEntity<>(parts, requestHeaders)); + * ResponseEntity<Void> response = restClient.post() + * .uri("https://example.com/myForm") + * .contentType(MULTIPART_MIXED) + * .body(form) + * .retrieve() + * .toBodilessEntity(); * *

        The following snippet shows how to do a file upload using the * {@code "multipart/related"} content type. * *

        - * MediaType multipartRelated = new MediaType("multipart", "related");
        - *
        - * restTemplate.getMessageConverters().stream()
        - *     .filter(FormHttpMessageConverter.class::isInstance)
        + * restClient = restClient.mutate()
        + *   .messageConverters(l -> l.stream()
        +  *    .filter(FormHttpMessageConverter.class::isInstance)
          *     .map(FormHttpMessageConverter.class::cast)
          *     .findFirst()
          *     .orElseThrow(() -> new IllegalStateException("Failed to find FormHttpMessageConverter"))
        - *     .addSupportedMediaTypes(multipartRelated);
        + *     .addSupportedMediaTypes(MULTIPART_RELATED);
          *
          * MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
          * parts.add("field 1", "value 1");
          * parts.add("file", new ClassPathResource("myFile.jpg"));
          *
        - * HttpHeaders requestHeaders = new HttpHeaders();
        - * requestHeaders.setContentType(multipartRelated);
        - *
        - * restTemplate.postForLocation("https://example.com/myFileUpload",
        - *     new HttpEntity<>(parts, requestHeaders));
        + * ResponseEntity<Void> response = restClient.post() + * .uri("https://example.com/myForm") + * .contentType(MULTIPART_RELATED) + * .body(form) + * .retrieve() + * .toBodilessEntity(); * *

        Miscellaneous

        * @@ -154,14 +164,9 @@ */ public class FormHttpMessageConverter implements HttpMessageConverter> { - /** - * The default charset used by the converter. - */ + /** The default charset used by the converter. */ public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; - private static final MediaType DEFAULT_FORM_DATA_MEDIA_TYPE = - new MediaType(MediaType.APPLICATION_FORM_URLENCODED, DEFAULT_CHARSET); - private List supportedMediaTypes = new ArrayList<>(); @@ -387,14 +392,13 @@ private boolean isMultipart(MultiValueMap map, @Nullable MediaType co return false; } - private void writeForm(MultiValueMap formData, @Nullable MediaType contentType, + private void writeForm(MultiValueMap formData, @Nullable MediaType mediaType, HttpOutputMessage outputMessage) throws IOException { - contentType = getFormContentType(contentType); - outputMessage.getHeaders().setContentType(contentType); + mediaType = getFormContentType(mediaType); + outputMessage.getHeaders().setContentType(mediaType); - Charset charset = contentType.getCharset(); - Assert.notNull(charset, "No charset"); // should never occur + Charset charset = (mediaType.getCharset() != null ? mediaType.getCharset() : this.charset); byte[] bytes = serializeForm(formData, charset).getBytes(charset); outputMessage.getHeaders().setContentLength(bytes.length); @@ -418,26 +422,22 @@ public boolean repeatable() { } /** - * Return the content type used to write forms, given the preferred content type. - * By default, this method returns the given content type, but adds the - * {@linkplain #setCharset(Charset) charset} if it does not have one. - * If {@code contentType} is {@code null}, - * {@code application/x-www-form-urlencoded; charset=UTF-8} is returned. - *

        Subclasses can override this method to change this behavior. - * @param contentType the preferred content type (can be {@code null}) - * @return the content type to be used + * Return the content type used to write forms, either the given content type + * or otherwise {@code application/x-www-form-urlencoded}. + * @param contentType the content type passed to {@link #write}, or {@code null} + * @return the content type to use * @since 5.2.2 */ protected MediaType getFormContentType(@Nullable MediaType contentType) { if (contentType == null) { - return DEFAULT_FORM_DATA_MEDIA_TYPE; + return MediaType.APPLICATION_FORM_URLENCODED; } - else if (contentType.getCharset() == null) { + // Some servers don't handle charset parameter and spec is unclear, + // Add it only if it is not DEFAULT_CHARSET. + if (contentType.getCharset() == null && this.charset != DEFAULT_CHARSET) { return new MediaType(contentType, this.charset); } - else { - return contentType; - } + return contentType; } protected String serializeForm(MultiValueMap formData, Charset charset) { diff --git a/spring-web/src/main/java/org/springframework/http/converter/GenericHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/GenericHttpMessageConverter.java index 905434e6e03c..760c92ace652 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/GenericHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/GenericHttpMessageConverter.java @@ -35,6 +35,7 @@ * @since 3.2 * @param the converted object type * @see org.springframework.core.ParameterizedTypeReference + * @see SmartHttpMessageConverter */ public interface GenericHttpMessageConverter extends HttpMessageConverter { @@ -53,7 +54,7 @@ public interface GenericHttpMessageConverter extends HttpMessageConverter boolean canRead(Type type, @Nullable Class contextClass, @Nullable MediaType mediaType); /** - * Read an object of the given type form the given input message, and returns it. + * Read an object of the given type from the given input message, and returns it. * @param type the (potentially generic) type of object to return. This type must have * previously been passed to the {@link #canRead canRead} method of this interface, * which must have returned {@code true}. diff --git a/spring-web/src/main/java/org/springframework/http/converter/HttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/HttpMessageConverter.java index 306a1b93a8f2..5c0ed6fc659f 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/HttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/HttpMessageConverter.java @@ -33,6 +33,7 @@ * @author Rossen Stoyanchev * @since 3.0 * @param the converted object type + * @see SmartHttpMessageConverter */ public interface HttpMessageConverter { diff --git a/spring-web/src/main/java/org/springframework/http/converter/ResourceRegionHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/ResourceRegionHttpMessageConverter.java index 68305acfed92..814be21ba14a 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/ResourceRegionHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/ResourceRegionHttpMessageConverter.java @@ -188,6 +188,7 @@ protected void writeResourceRegion(ResourceRegion region, HttpOutputMessage outp } } + @SuppressWarnings("NullAway") private void writeResourceRegionCollection(Collection resourceRegions, HttpOutputMessage outputMessage) throws IOException { diff --git a/spring-web/src/main/java/org/springframework/http/converter/SmartHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/SmartHttpMessageConverter.java new file mode 100644 index 000000000000..98b1d44448b4 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/SmartHttpMessageConverter.java @@ -0,0 +1,132 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.converter; + +import java.io.IOException; +import java.util.Map; + +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; + +/** + * A specialization of {@link HttpMessageConverter} that can convert an HTTP request + * into a target object of a specified {@link ResolvableType} and a source object of + * a specified {@link ResolvableType} into an HTTP response with optional hints. + * + *

        It provides default methods for {@link HttpMessageConverter} in order to allow + * subclasses to only have to implement the smart APIs. + * + * @author Sebastien Deleuze + * @since 6.2 + * @param the converted object type + */ +public interface SmartHttpMessageConverter extends HttpMessageConverter { + + /** + * Indicates whether the given type can be read by this converter. + * This method should perform the same checks as + * {@link HttpMessageConverter#canRead(Class, MediaType)} with additional ones + * related to the generic type. + * @param type the (potentially generic) type to test for readability. The + * {@linkplain ResolvableType#getSource() type source} may be used for retrieving + * additional information (the related method signature for example) when relevant. + * @param mediaType the media type to read, can be {@code null} if not specified. + * Typically, the value of a {@code Content-Type} header. + * @return {@code true} if readable; {@code false} otherwise + */ + boolean canRead(ResolvableType type, @Nullable MediaType mediaType); + + @Override + default boolean canRead(Class clazz, @Nullable MediaType mediaType) { + return canRead(ResolvableType.forClass(clazz), mediaType); + } + + /** + * Read an object of the given type from the given input message, and returns it. + * @param type the (potentially generic) type of object to return. This type must have + * previously been passed to the {@link #canRead(ResolvableType, MediaType) canRead} + * method of this interface, which must have returned {@code true}. The + * {@linkplain ResolvableType#getSource() type source} may be used for retrieving + * additional information (the related method signature for example) when relevant. + * @param inputMessage the HTTP input message to read from + * @param hints additional information about how to encode + * @return the converted object + * @throws IOException in case of I/O errors + * @throws HttpMessageNotReadableException in case of conversion errors + */ + T read(ResolvableType type, HttpInputMessage inputMessage, @Nullable Map hints) + throws IOException, HttpMessageNotReadableException; + + @Override + default T read(Class clazz, HttpInputMessage inputMessage) + throws IOException, HttpMessageNotReadableException { + + return read(ResolvableType.forClass(clazz), inputMessage, null); + } + + /** + * Indicates whether the given class can be written by this converter. + *

        This method should perform the same checks as + * {@link HttpMessageConverter#canWrite(Class, MediaType)} with additional ones + * related to the generic type. + * @param targetType the (potentially generic) target type to test for writability + * (can be {@link ResolvableType#NONE} if not specified). The {@linkplain ResolvableType#getSource() type source} + * may be used for retrieving additional information (the related method signature for example) when relevant. + * @param valueClass the source object class to test for writability + * @param mediaType the media type to write (can be {@code null} if not specified); + * typically the value of an {@code Accept} header. + * @return {@code true} if writable; {@code false} otherwise + */ + boolean canWrite(ResolvableType targetType, Class valueClass, @Nullable MediaType mediaType); + + @Override + default boolean canWrite(Class clazz, @Nullable MediaType mediaType) { + return canWrite(ResolvableType.forClass(clazz), clazz, mediaType); + } + + /** + * Write a given object to the given output message. + * @param t the object to write to the output message. The type of this object must + * have previously been passed to the {@link #canWrite canWrite} method of this + * interface, which must have returned {@code true}. + * @param type the (potentially generic) type of object to write. This type must have + * previously been passed to the {@link #canWrite canWrite} method of this interface, + * which must have returned {@code true}. Can be {@link ResolvableType#NONE} if not specified. + * The {@linkplain ResolvableType#getSource() type source} may be used for retrieving additional + * information (the related method signature for example) when relevant. + * @param contentType the content type to use when writing. May be {@code null} to + * indicate that the default content type of the converter must be used. If not + * {@code null}, this media type must have previously been passed to the + * {@link #canWrite canWrite} method of this interface, which must have returned + * {@code true}. + * @param outputMessage the message to write to + * @param hints additional information about how to encode + * @throws IOException in case of I/O errors + * @throws HttpMessageNotWritableException in case of conversion errors + */ + void write(T t, ResolvableType type, @Nullable MediaType contentType, HttpOutputMessage outputMessage, + @Nullable Map hints) throws IOException, HttpMessageNotWritableException; + + @Override + default void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage) + throws IOException, HttpMessageNotWritableException { + write(t, ResolvableType.forInstance(t), contentType, outputMessage, null); + } +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/cbor/MappingJackson2CborHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/cbor/MappingJackson2CborHttpMessageConverter.java index 7d49906a9740..6e0b808db6dc 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/cbor/MappingJackson2CborHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/cbor/MappingJackson2CborHttpMessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +65,7 @@ public MappingJackson2CborHttpMessageConverter(ObjectMapper objectMapper) { /** * {@inheritDoc} - * The {@code ObjectMapper} must be configured with a {@code CBORFactory} instance. + *

        The {@code ObjectMapper} must be configured with a {@code CBORFactory} instance. */ @Override public void setObjectMapper(ObjectMapper objectMapper) { 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 81c7165b5ccc..89320c8656c9 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 @@ -249,6 +249,7 @@ public boolean canRead(Class clazz, @Nullable MediaType mediaType) { return canRead(clazz, null, mediaType); } + @SuppressWarnings("deprecation") // as of Jackson 2.18: can(De)Serialize @Override public boolean canRead(Type type, @Nullable Class contextClass, @Nullable MediaType mediaType) { if (!canRead(mediaType)) { @@ -267,6 +268,7 @@ public boolean canRead(Type type, @Nullable Class contextClass, @Nullable Med return false; } + @SuppressWarnings("deprecation") // as of Jackson 2.18: can(De)Serialize @Override public boolean canWrite(Class clazz, @Nullable MediaType mediaType) { if (!canWrite(mediaType)) { @@ -329,7 +331,8 @@ protected void logWarningIfNecessary(Type type, @Nullable Throwable cause) { } // Do not log warning for serializer not found (note: different message wording on Jackson 2.9) - boolean debugLevel = (cause instanceof JsonMappingException && cause.getMessage().startsWith("Cannot find")); + boolean debugLevel = (cause instanceof JsonMappingException && cause.getMessage() != null && + cause.getMessage().startsWith("Cannot find")); if (debugLevel ? logger.isDebugEnabled() : logger.isWarnEnabled()) { String msg = "Failed to evaluate Jackson " + (type instanceof JavaType ? "de" : "") + diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJsonHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJsonHttpMessageConverter.java index 4584b42913e7..1f73d60f92c6 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJsonHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJsonHttpMessageConverter.java @@ -36,7 +36,7 @@ import org.springframework.lang.Nullable; /** - * Common base class for plain JSON converters, e.g. Gson and JSON-B. + * Common base class for plain JSON converters, for example, Gson and JSON-B. * *

        Note that the Jackson converters have a dedicated class hierarchy * due to their multi-format support. diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/GsonHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/GsonHttpMessageConverter.java index e872d7daf4ca..d3306f2eb463 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/GsonHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/GsonHttpMessageConverter.java @@ -96,7 +96,7 @@ protected Object readInternal(Type resolvedType, Reader reader) throws Exception protected void writeInternal(Object object, @Nullable Type type, Writer writer) throws Exception { // In Gson, toJson with a type argument will exclusively use that given type, // ignoring the actual type of the object... which might be more specific, - // e.g. a subclass of the specified type which includes additional fields. + // for example, a subclass of the specified type which includes additional fields. // As a consequence, we're only passing in parameterized type declarations // which might contain extra generics that the object instance doesn't retain. if (type instanceof ParameterizedType) { diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilder.java b/spring-web/src/main/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilder.java index 669ef837f9be..a76e9033889e 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilder.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilder.java @@ -56,6 +56,7 @@ import com.fasterxml.jackson.dataformat.xml.JacksonXmlModule; import com.fasterxml.jackson.dataformat.xml.XmlFactory; import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import org.springframework.beans.BeanUtils; import org.springframework.context.ApplicationContext; @@ -87,7 +88,7 @@ * support for Java 8 Date & Time API types *

      • jackson-module-kotlin: * support for Kotlin classes and data classes
      • - *
      • jackson-modules-java8/parameter-names: + *
      • jackson-modules-java8/parameter-names: * support for accessing parameter names
      • * * @@ -95,6 +96,7 @@ * @author Juergen Hoeller * @author Tadaya Tsuyukubo * @author Eddú Meléndez + * @author Hyoungjune Kim * @since 4.1.1 * @see #build() * @see #configure(ObjectMapper) @@ -265,7 +267,7 @@ public Jackson2ObjectMapperBuilder annotationIntrospector(AnnotationIntrospector /** * Alternative to {@link #annotationIntrospector(AnnotationIntrospector)} * that allows combining with rather than replacing the currently set - * introspector — for example, via + * introspector, for example, via * {@link AnnotationIntrospectorPair#pair(AnnotationIntrospector, AnnotationIntrospector)}. * @param pairingFunction a function to apply to the currently set * introspector (possibly {@code null}); the result of the function becomes @@ -932,6 +934,15 @@ public static Jackson2ObjectMapperBuilder cbor() { return new Jackson2ObjectMapperBuilder().factory(new CborFactoryInitializer().create()); } + /** + * Obtain a {@link Jackson2ObjectMapperBuilder} instance in order to + * build a YAML data format {@link ObjectMapper} instance. + * @since 6.2 + */ + public static Jackson2ObjectMapperBuilder yaml() { + return new Jackson2ObjectMapperBuilder().factory(new YamlFactoryInitializer().create()); + } + private static class XmlObjectMapperInitializer { @@ -972,4 +983,11 @@ public JsonFactory create() { } } + private static class YamlFactoryInitializer { + + public JsonFactory create() { + return new YAMLFactory(); + } + } + } diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/ProblemDetailJacksonXmlMixin.java b/spring-web/src/main/java/org/springframework/http/converter/json/ProblemDetailJacksonXmlMixin.java index ccaccc4ebf15..b2b28027962d 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/ProblemDetailJacksonXmlMixin.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/ProblemDetailJacksonXmlMixin.java @@ -44,33 +44,33 @@ * @since 6.0.5 */ @JsonInclude(NON_EMPTY) -@JacksonXmlRootElement(localName = "problem", namespace = ProblemDetailJacksonXmlMixin.RFC_7807_NAMESPACE) +@JacksonXmlRootElement(localName = "problem", namespace = ProblemDetailJacksonXmlMixin.NAMESPACE) public interface ProblemDetailJacksonXmlMixin { - /** RFC 7807 namespace. */ - String RFC_7807_NAMESPACE = "urn:ietf:rfc:7807"; + /** RFC 7807 (obsoleted by RFC 9457) namespace. */ + String NAMESPACE = "urn:ietf:rfc:7807"; - @JacksonXmlProperty(namespace = RFC_7807_NAMESPACE) + @JacksonXmlProperty(namespace = NAMESPACE) URI getType(); - @JacksonXmlProperty(namespace = RFC_7807_NAMESPACE) + @JacksonXmlProperty(namespace = NAMESPACE) String getTitle(); - @JacksonXmlProperty(namespace = RFC_7807_NAMESPACE) + @JacksonXmlProperty(namespace = NAMESPACE) int getStatus(); - @JacksonXmlProperty(namespace = RFC_7807_NAMESPACE) + @JacksonXmlProperty(namespace = NAMESPACE) String getDetail(); - @JacksonXmlProperty(namespace = RFC_7807_NAMESPACE) + @JacksonXmlProperty(namespace = NAMESPACE) URI getInstance(); @JsonAnySetter void setProperty(String name, @Nullable Object value); @JsonAnyGetter - @JacksonXmlProperty(namespace = RFC_7807_NAMESPACE) + @JacksonXmlProperty(namespace = NAMESPACE) Map getProperties(); } diff --git a/spring-web/src/main/java/org/springframework/http/converter/protobuf/ProtobufHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/protobuf/ProtobufHttpMessageConverter.java index a87143836deb..72029653dbb9 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/protobuf/ProtobufHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/protobuf/ProtobufHttpMessageConverter.java @@ -203,7 +203,6 @@ protected boolean canWrite(@Nullable MediaType mediaType) { (this.protobufFormatSupport != null && this.protobufFormatSupport.supportsWriteOnly(mediaType))); } - @SuppressWarnings("deprecation") @Override protected void writeInternal(Message message, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException { @@ -226,7 +225,7 @@ protected void writeInternal(Message message, HttpOutputMessage outputMessage) } else if (TEXT_PLAIN.isCompatibleWith(contentType)) { OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputMessage.getBody(), charset); - TextFormat.print(message, outputStreamWriter); // deprecated on Protobuf 3.9 + TextFormat.printer().print(message, outputStreamWriter); outputStreamWriter.flush(); outputMessage.getBody().flush(); } diff --git a/spring-web/src/main/java/org/springframework/http/converter/smile/MappingJackson2SmileHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/smile/MappingJackson2SmileHttpMessageConverter.java index da51c2f6f6a6..c6f08464ece5 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/smile/MappingJackson2SmileHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/smile/MappingJackson2SmileHttpMessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -62,7 +62,7 @@ public MappingJackson2SmileHttpMessageConverter(ObjectMapper objectMapper) { /** * {@inheritDoc} - * The {@code ObjectMapper} must be configured with a {@code SmileFactory} instance. + *

        The {@code ObjectMapper} must be configured with a {@code SmileFactory} instance. */ @Override public void setObjectMapper(ObjectMapper objectMapper) { diff --git a/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java index 8a7fd943d58c..7f4b4a95258d 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +18,7 @@ import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.http.converter.cbor.KotlinSerializationCborHttpMessageConverter; +import org.springframework.http.converter.cbor.MappingJackson2CborHttpMessageConverter; import org.springframework.http.converter.json.GsonHttpMessageConverter; import org.springframework.http.converter.json.JsonbHttpMessageConverter; import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter; @@ -26,11 +27,13 @@ import org.springframework.http.converter.smile.MappingJackson2SmileHttpMessageConverter; import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter; import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; +import org.springframework.http.converter.yaml.MappingJackson2YamlHttpMessageConverter; import org.springframework.util.ClassUtils; /** * Extension of {@link org.springframework.http.converter.FormHttpMessageConverter}, - * adding support for XML and JSON-based parts. + * adding support for XML, JSON, Smile, CBOR, Protobuf and Yaml based parts when + * related libraries are present in the classpath. * * @author Rossen Stoyanchev * @author Juergen Hoeller @@ -47,6 +50,10 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv private static final boolean jackson2SmilePresent; + private static final boolean jackson2CborPresent; + + private static final boolean jackson2YamlPresent; + private static final boolean gsonPresent; private static final boolean jsonbPresent; @@ -64,6 +71,8 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader); jackson2XmlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader); jackson2SmilePresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader); + jackson2CborPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", classLoader); + jackson2YamlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.yaml.YAMLFactory", classLoader); gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); jsonbPresent = ClassUtils.isPresent("jakarta.json.bind.Jsonb", classLoader); kotlinSerializationCborPresent = ClassUtils.isPresent("kotlinx.serialization.cbor.Cbor", classLoader); @@ -99,6 +108,14 @@ else if (jsonbPresent) { addPartConverter(new MappingJackson2SmileHttpMessageConverter()); } + if (jackson2CborPresent) { + addPartConverter(new MappingJackson2CborHttpMessageConverter()); + } + + if (jackson2YamlPresent) { + addPartConverter(new MappingJackson2YamlHttpMessageConverter()); + } + if (kotlinSerializationCborPresent) { addPartConverter(new KotlinSerializationCborHttpMessageConverter()); } diff --git a/spring-web/src/main/java/org/springframework/http/converter/xml/Jaxb2RootElementHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/xml/Jaxb2RootElementHttpMessageConverter.java index dda603995a16..8cc89318184a 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/xml/Jaxb2RootElementHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/xml/Jaxb2RootElementHttpMessageConverter.java @@ -121,7 +121,9 @@ public boolean canRead(Class clazz, @Nullable MediaType mediaType) { @Override public boolean canWrite(Class clazz, @Nullable MediaType mediaType) { - return (AnnotationUtils.findAnnotation(clazz, XmlRootElement.class) != null && canWrite(mediaType)); + boolean supportedType = (JAXBElement.class.isAssignableFrom(clazz) || + AnnotationUtils.findAnnotation(clazz, XmlRootElement.class) != null); + return (supportedType && canWrite(mediaType)); } @Override @@ -162,6 +164,8 @@ protected Source processSource(Source source) { if (source instanceof StreamSource streamSource) { InputSource inputSource = new InputSource(streamSource.getInputStream()); try { + // By default, Spring will prevent the processing of external entities. + // This is a mitigation against XXE attacks. SAXParserFactory saxParserFactory = this.sourceParserFactory; if (saxParserFactory == null) { saxParserFactory = SAXParserFactory.newInstance(); @@ -190,12 +194,12 @@ protected Source processSource(Source source) { } @Override - protected void writeToResult(Object o, HttpHeaders headers, Result result) throws Exception { + protected void writeToResult(Object value, HttpHeaders headers, Result result) throws Exception { try { - Class clazz = ClassUtils.getUserClass(o); + Class clazz = getMarshallerType(value); Marshaller marshaller = createMarshaller(clazz); setCharset(headers.getContentType(), marshaller); - marshaller.marshal(o, result); + marshaller.marshal(value, result); } catch (MarshalException ex) { throw ex; @@ -205,6 +209,15 @@ protected void writeToResult(Object o, HttpHeaders headers, Result result) throw } } + private static Class getMarshallerType(Object value) { + if (value instanceof JAXBElement jaxbElement) { + return jaxbElement.getDeclaredType(); + } + else { + return ClassUtils.getUserClass(value); + } + } + private void setCharset(@Nullable MediaType contentType, Marshaller marshaller) throws PropertyException { if (contentType != null && contentType.getCharset() != null) { marshaller.setProperty(Marshaller.JAXB_ENCODING, contentType.getCharset().name()); diff --git a/spring-web/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java index 7295dffe72ff..d2553f13f0c7 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 MappingJackson2XmlHttpMessageConverter(ObjectMapper objectMapper) { /** * {@inheritDoc} - * The {@code ObjectMapper} parameter must be a {@link XmlMapper} instance. + *

        The {@code ObjectMapper} parameter must be an {@link XmlMapper} instance. */ @Override public void setObjectMapper(ObjectMapper objectMapper) { diff --git a/spring-web/src/main/java/org/springframework/http/converter/xml/SourceHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/xml/SourceHttpMessageConverter.java index c99d0bb033b7..1483ea0b4c6e 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/xml/SourceHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/xml/SourceHttpMessageConverter.java @@ -177,6 +177,8 @@ else if (StreamSource.class == clazz || Source.class == clazz) { private DOMSource readDOMSource(InputStream body, HttpInputMessage inputMessage) throws IOException { try { + // By default, Spring will prevent the processing of external entities. + // This is a mitigation against XXE attacks. DocumentBuilderFactory builderFactory = this.documentBuilderFactory; if (builderFactory == null) { builderFactory = DocumentBuilderFactory.newInstance(); diff --git a/spring-web/src/main/java/org/springframework/http/converter/yaml/MappingJackson2YamlHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/yaml/MappingJackson2YamlHttpMessageConverter.java new file mode 100644 index 000000000000..253d2552e587 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/yaml/MappingJackson2YamlHttpMessageConverter.java @@ -0,0 +1,76 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.converter.yaml; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.util.Assert; + +/** + * Implementation of {@link org.springframework.http.converter.HttpMessageConverter + * HttpMessageConverter} that can read and write the YAML + * data format using + * the dedicated Jackson 2.x extension. + * + *

        By default, this converter supports the {@link MediaType#APPLICATION_YAML_VALUE} + * media type. This can be overridden by setting the {@link #setSupportedMediaTypes + * supportedMediaTypes} property. + * + *

        The default constructor uses the default configuration provided by + * {@link Jackson2ObjectMapperBuilder}. + * + * @author Hyoungjune Kim + * @since 6.2 + */ +public class MappingJackson2YamlHttpMessageConverter extends AbstractJackson2HttpMessageConverter { + + /** + * Construct a new {@code MappingJackson2YamlHttpMessageConverter} using the + * default configuration provided by {@code Jackson2ObjectMapperBuilder}. + */ + public MappingJackson2YamlHttpMessageConverter() { + this(Jackson2ObjectMapperBuilder.yaml().build()); + } + + /** + * Construct a new {@code MappingJackson2YamlHttpMessageConverter} with a + * custom {@link ObjectMapper} (must be configured with a {@code YAMLFactory} + * instance). + *

        You can use {@link Jackson2ObjectMapperBuilder} to build it easily. + * @see Jackson2ObjectMapperBuilder#yaml() + */ + public MappingJackson2YamlHttpMessageConverter(ObjectMapper objectMapper) { + super(objectMapper, MediaType.APPLICATION_YAML); + Assert.isInstanceOf(YAMLFactory.class, objectMapper.getFactory(), "YAMLFactory required"); + } + + + /** + * {@inheritDoc} + *

        The {@code ObjectMapper} must be configured with a {@code YAMLFactory} instance. + */ + @Override + public void setObjectMapper(ObjectMapper objectMapper) { + Assert.isInstanceOf(YAMLFactory.class, objectMapper.getFactory(), "YAMLFactory required"); + super.setObjectMapper(objectMapper); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/yaml/package-info.java b/spring-web/src/main/java/org/springframework/http/converter/yaml/package-info.java new file mode 100644 index 000000000000..ef3a64919289 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/yaml/package-info.java @@ -0,0 +1,9 @@ +/** + * Provides an {@code HttpMessageConverter} for the YAML data format. + */ +@NonNullApi +@NonNullFields +package org.springframework.http.converter.yaml; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpAsyncRequestControl.java b/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpAsyncRequestControl.java index 1b50f301021a..8cb414d8c912 100644 --- a/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpAsyncRequestControl.java +++ b/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpAsyncRequestControl.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpRequest.java index 85850d138ffc..39dbd08d4d2d 100644 --- a/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpRequest.java @@ -29,11 +29,16 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.security.Principal; +import java.util.AbstractCollection; +import java.util.AbstractMap; +import java.util.AbstractSet; import java.util.Arrays; +import java.util.Collection; import java.util.Enumeration; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; import jakarta.servlet.http.HttpServletRequest; @@ -45,6 +50,7 @@ import org.springframework.util.Assert; import org.springframework.util.LinkedCaseInsensitiveMap; import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; /** * {@link ServerHttpRequest} implementation that is based on a {@link HttpServletRequest}. @@ -67,6 +73,10 @@ public class ServletServerHttpRequest implements ServerHttpRequest { @Nullable private HttpHeaders headers; + @Nullable + private Map attributes; + + @Nullable private ServerHttpAsyncRequestControl asyncRequestControl; @@ -110,31 +120,37 @@ public URI getURI() { */ public static URI initURI(HttpServletRequest servletRequest) { String urlString = null; + String query = null; boolean hasQuery = false; try { - StringBuffer url = servletRequest.getRequestURL(); - String query = servletRequest.getQueryString(); + StringBuffer requestURL = servletRequest.getRequestURL(); + query = servletRequest.getQueryString(); hasQuery = StringUtils.hasText(query); if (hasQuery) { - url.append('?').append(query); + requestURL.append('?').append(query); } - urlString = url.toString(); + urlString = requestURL.toString(); return new URI(urlString); } catch (URISyntaxException ex) { - if (!hasQuery) { - throw new IllegalStateException( - "Could not resolve HttpServletRequest as URI: " + urlString, ex); - } - // Maybe a malformed query string... try plain request URL - try { - urlString = servletRequest.getRequestURL().toString(); - return new URI(urlString); - } - catch (URISyntaxException ex2) { - throw new IllegalStateException( - "Could not resolve HttpServletRequest as URI: " + urlString, ex2); + if (hasQuery) { + try { + // Maybe malformed query, try to parse and encode it + query = UriComponentsBuilder.fromUriString("?" + query).build().toUri().getRawQuery(); + return new URI(servletRequest.getRequestURL().toString() + "?" + query); + } + catch (URISyntaxException ex2) { + try { + // Try leaving it out + return new URI(servletRequest.getRequestURL().toString()); + } + catch (URISyntaxException ex3) { + // ignore + } + } } + throw new IllegalStateException( + "Could not resolve HttpServletRequest as URI: " + urlString, ex); } } @@ -207,6 +223,16 @@ public InetSocketAddress getRemoteAddress() { return new InetSocketAddress(this.servletRequest.getRemoteHost(), this.servletRequest.getRemotePort()); } + @Override + public Map getAttributes() { + Map attributes = this.attributes; + if (attributes == null) { + attributes = new AttributesMap(); + this.attributes = attributes; + } + return attributes; + } + @Override public InputStream getBody() throws IOException { if (isFormPost(this.servletRequest) && this.servletRequest.getQueryString() == null) { @@ -276,4 +302,151 @@ private InputStream getBodyFromServletRequestParameters(HttpServletRequest reque return new ByteArrayInputStream(bytes); } + + private final class AttributesMap extends AbstractMap { + + @Nullable + private transient Set keySet; + + @Nullable + private transient Collection values; + + @Nullable + private transient Set> entrySet; + + + @Override + public int size() { + int size = 0; + for (Enumeration names = servletRequest.getAttributeNames(); names.hasMoreElements(); names.nextElement()) { + size++; + } + return size; + } + + @Override + @Nullable + public Object get(Object key) { + if (key instanceof String name) { + return servletRequest.getAttribute(name); + } + else { + return null; + } + } + + @Override + @Nullable + public Object put(String key, Object value) { + Object old = get(key); + servletRequest.setAttribute(key, value); + return old; + } + + @Override + @Nullable + public Object remove(Object key) { + if (key instanceof String name) { + Object old = get(key); + servletRequest.removeAttribute(name); + return old; + } + else { + return null; + } + } + + @Override + public void clear() { + for (Enumeration names = servletRequest.getAttributeNames(); names.hasMoreElements(); ) { + String name = names.nextElement(); + servletRequest.removeAttribute(name); + } + } + + @Override + public Set keySet() { + Set keySet = this.keySet; + if (keySet == null) { + keySet = new AbstractSet<>() { + @Override + public Iterator iterator() { + return servletRequest.getAttributeNames().asIterator(); + } + + @Override + public int size() { + return AttributesMap.this.size(); + } + }; + this.keySet = keySet; + } + return keySet; + } + + @Override + public Collection values() { + Collection values = this.values; + if (values == null) { + values = new AbstractCollection<>() { + @Override + public Iterator iterator() { + Enumeration e = servletRequest.getAttributeNames(); + return new Iterator<>() { + @Override + public boolean hasNext() { + return e.hasMoreElements(); + } + + @Override + public Object next() { + String name = e.nextElement(); + return servletRequest.getAttribute(name); + } + }; + } + + @Override + public int size() { + return AttributesMap.this.size(); + } + }; + this.values = values; + } + return values; + } + + @Override + public Set> entrySet() { + Set> entrySet = this.entrySet; + if (entrySet == null) { + entrySet = new AbstractSet<>() { + @Override + public Iterator> iterator() { + Enumeration e = servletRequest.getAttributeNames(); + return new Iterator<>() { + @Override + public boolean hasNext() { + return e.hasMoreElements(); + } + + @Override + public Entry next() { + String name = e.nextElement(); + Object value = servletRequest.getAttribute(name); + return new SimpleImmutableEntry<>(name, value); + } + }; + } + + @Override + public int size() { + return AttributesMap.this.size(); + } + }; + this.entrySet = entrySet; + } + return entrySet; + } + } } diff --git a/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpResponse.java index 875e5bd3127a..f6d3ef6cbdf5 100644 --- a/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpResponse.java @@ -142,7 +142,7 @@ private void writeHeaders() { * *

        The intent is merely to expose what is available through the HttpServletResponse * i.e. the ability to look up specific header values by name. All other - * map-related operations (e.g. iteration, removal, etc) apply only to values + * map-related operations (for example, iteration, removal, etc) apply only to values * added directly through HttpHeaders methods. * * @since 4.0.3 @@ -162,7 +162,7 @@ public String getFirst(String headerName) { if (headerName.equalsIgnoreCase(CONTENT_TYPE)) { // Content-Type is written as an override so check super first String value = super.getFirst(headerName); - return (value != null ? value : servletResponse.getHeader(headerName)); + return (value != null ? value : servletResponse.getContentType()); } else { String value = servletResponse.getHeader(headerName); 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 a3753486c35a..72d68e8b052d 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 @@ -47,6 +47,7 @@ * @since 5.0 * @param the type of element signaled */ +@SuppressWarnings("NullAway") public abstract class AbstractListenerReadPublisher implements Publisher { /** 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 a7665be28708..2392f85ea924 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 @@ -40,6 +40,7 @@ * @since 5.0 * @param the type of element signaled to the {@link Subscriber} */ +@SuppressWarnings("NullAway") public abstract class AbstractListenerWriteFlushProcessor implements Processor, Void> { /** 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 7e81b6e72b3b..ef9b8976b1b1 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 @@ -43,6 +43,7 @@ * @since 5.0 * @param the type of element signaled to the {@link Subscriber} */ +@SuppressWarnings("NullAway") public abstract class AbstractListenerWriteProcessor implements Processor { /** diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpRequest.java index 829a2202a814..9c9fea5e3ccf 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,9 @@ import java.net.URI; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; +import java.util.function.Supplier; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -47,7 +50,11 @@ public abstract class AbstractServerHttpRequest implements ServerHttpRequest { private final URI uri; - private final RequestPath path; + @Nullable + private final String contextPath; + + @Nullable + private RequestPath path; private final HttpHeaders headers; @@ -68,6 +75,9 @@ public abstract class AbstractServerHttpRequest implements ServerHttpRequest { @Nullable private String logPrefix; + @Nullable + private Supplier> attributesSupplier; + /** * Constructor with the method, URI and headers for the request. @@ -86,7 +96,7 @@ public AbstractServerHttpRequest(HttpMethod method, URI uri, @Nullable String co this.method = method; this.uri = uri; - this.path = RequestPath.parse(uri, contextPath); + this.contextPath = contextPath; this.headers = HttpHeaders.readOnlyHttpHeaders(headers); } @@ -122,8 +132,21 @@ public URI getURI() { return this.uri; } + @Override + public Map getAttributes() { + if (this.attributesSupplier != null) { + return this.attributesSupplier.get(); + } + else { + return Collections.emptyMap(); + } + } + @Override public RequestPath getPath() { + if (this.path == null) { + this.path = RequestPath.parse(this.uri, this.contextPath); + } return this.path; } @@ -230,4 +253,12 @@ protected String initLogPrefix() { return getId(); } + /** + * Set the attribute supplier. + *

        Note: This is exposed mainly for internal framework + * use. + */ + public void setAttributesSupplier(Supplier> attributesSupplier) { + this.attributesSupplier = attributesSupplier; + } } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ChannelSendOperator.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ChannelSendOperator.java index e9aa4e13e7d8..6b1bf8d7e1f9 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ChannelSendOperator.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ChannelSendOperator.java @@ -99,7 +99,7 @@ private enum State { * The write subscriber has subscribed, and cached signals have been * emitted to it; we're ready to switch to a simple pass-through mode * for all remaining signals. - **/ + */ READY_TO_WRITE } @@ -173,7 +173,7 @@ public final void onNext(T item) { requiredWriteSubscriber().onNext(item); return; } - //FIXME revisit in case of reentrant sync deadlock + // FIXME revisit in case of reentrant sync deadlock synchronized (this) { if (this.state == State.READY_TO_WRITE) { requiredWriteSubscriber().onNext(item); diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ContextPathCompositeHandler.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ContextPathCompositeHandler.java index 42d63aea5258..97302037ebd7 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ContextPathCompositeHandler.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ContextPathCompositeHandler.java @@ -63,7 +63,7 @@ private static void assertValidContextPath(String contextPath) { @Override public Mono handle(ServerHttpRequest request, ServerHttpResponse response) { - // Remove underlying context path first (e.g. Servlet container) + // Remove underlying context path first (for example, Servlet container) String path = request.getPath().pathWithinApplication().value(); return this.handlerMap.entrySet().stream() .filter(entry -> path.startsWith(entry.getKey())) 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 2762104246fb..6157e38ee765 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 @@ -70,7 +70,12 @@ public DefaultServerHttpRequestBuilder(ServerHttpRequest original) { Assert.notNull(original, "ServerHttpRequest is required"); this.uri = original.getURI(); - this.headers = HttpHeaders.writableHttpHeaders(original.getHeaders()); + // Some containers (including Jetty and Netty4) can have an immutable + // representation of headers. Since mutability is always desirable here, + // we always create a mutable case-insensitive copy of the original + // headers by using the basic constructor and addAll. + this.headers = new HttpHeaders(); + this.headers.addAll(original.getHeaders()); this.httpMethod = original.getMethod(); this.contextPath = original.getPath().contextPath().value(); this.remoteAddress = original.getRemoteAddress(); diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/JettyCoreHttpHandlerAdapter.java b/spring-web/src/main/java/org/springframework/http/server/reactive/JettyCoreHttpHandlerAdapter.java new file mode 100644 index 000000000000..08994a11f624 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/JettyCoreHttpHandlerAdapter.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.server.reactive; + +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; + +import org.springframework.core.io.buffer.JettyDataBufferFactory; +import org.springframework.util.Assert; + +/** + * Adapt {@link HttpHandler} to the Jetty {@link org.eclipse.jetty.server.Handler} abstraction. + * + * @author Greg Wilkins + * @author Lachlan Roberts + * @author Arjen Poutsma + * @since 6.2 + */ +public class JettyCoreHttpHandlerAdapter extends Handler.Abstract.NonBlocking { + + private final HttpHandler httpHandler; + + private JettyDataBufferFactory dataBufferFactory = new JettyDataBufferFactory(); + + + public JettyCoreHttpHandlerAdapter(HttpHandler httpHandler) { + this.httpHandler = httpHandler; + } + + public void setDataBufferFactory(JettyDataBufferFactory dataBufferFactory) { + Assert.notNull(dataBufferFactory, "DataBufferFactory must not be null"); + this.dataBufferFactory = dataBufferFactory; + } + + + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception { + this.httpHandler.handle(new JettyCoreServerHttpRequest(request, this.dataBufferFactory), + new JettyCoreServerHttpResponse(response, this.dataBufferFactory)) + .subscribe(unused -> {}, callback::failed, callback::succeeded); + return true; + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/JettyCoreServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/reactive/JettyCoreServerHttpRequest.java new file mode 100644 index 000000000000..d97832a2ff7c --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/JettyCoreServerHttpRequest.java @@ -0,0 +1,120 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.server.reactive; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.Collections; +import java.util.List; + +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.io.EndPoint; +import org.eclipse.jetty.server.Request; +import org.reactivestreams.FlowAdapters; +import reactor.core.publisher.Flux; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.JettyDataBufferFactory; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.support.JettyHeadersAdapter; +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Adapt an Eclipse Jetty {@link Request} to a {@link org.springframework.http.server.ServerHttpRequest}. + * + * @author Greg Wilkins + * @author Arjen Poutsma + * @since 6.2 + */ +class JettyCoreServerHttpRequest extends AbstractServerHttpRequest { + + private final JettyDataBufferFactory dataBufferFactory; + + private final Request request; + + + public JettyCoreServerHttpRequest(Request request, JettyDataBufferFactory dataBufferFactory) { + super(HttpMethod.valueOf(request.getMethod()), + request.getHttpURI().toURI(), + request.getContext().getContextPath(), + new HttpHeaders(new JettyHeadersAdapter(request.getHeaders()))); + this.dataBufferFactory = dataBufferFactory; + this.request = request; + } + + @Override + protected MultiValueMap initCookies() { + List httpCookies = Request.getCookies(this.request); + if (httpCookies.isEmpty()) { + return CollectionUtils.toMultiValueMap(Collections.emptyMap()); + } + MultiValueMap cookies =new LinkedMultiValueMap<>(); + for (org.eclipse.jetty.http.HttpCookie c : httpCookies) { + cookies.add(c.getName(), new HttpCookie(c.getName(), c.getValue())); + } + return cookies; + } + + @Override + @Nullable + public SslInfo initSslInfo() { + if (this.request.getConnectionMetaData().isSecure() && + this.request.getAttribute(EndPoint.SslSessionData.ATTRIBUTE) instanceof EndPoint.SslSessionData sessionData) { + return new DefaultSslInfo(sessionData.sslSessionId(), sessionData.peerCertificates()); + } + return null; + } + + @SuppressWarnings("unchecked") + @Override + public T getNativeRequest() { + return (T) this.request; + } + + @Override + protected String initId() { + return this.request.getId(); + } + + @Override + @Nullable + public InetSocketAddress getLocalAddress() { + SocketAddress localAddress = this.request.getConnectionMetaData().getLocalSocketAddress(); + return localAddress instanceof InetSocketAddress inet ? inet : null; + } + + @Override + @Nullable + public InetSocketAddress getRemoteAddress() { + SocketAddress remoteAddress = this.request.getConnectionMetaData().getRemoteSocketAddress(); + return remoteAddress instanceof InetSocketAddress inet ? inet : null; + } + + @Override + public Flux getBody() { + // We access the request body as a Flow.Publisher, which is wrapped as an org.reactivestreams.Publisher and + // then wrapped as a Flux. + return Flux.from(FlowAdapters.toPublisher(Content.Source.asPublisher(this.request))) + .map(this.dataBufferFactory::wrap); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/JettyCoreServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/reactive/JettyCoreServerHttpResponse.java new file mode 100644 index 000000000000..a53cab42190a --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/JettyCoreServerHttpResponse.java @@ -0,0 +1,241 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.server.reactive; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.ListIterator; +import java.util.Map; + +import org.eclipse.jetty.http.HttpCookie; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.HttpCookieUtils; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.IteratingCallback; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.JettyDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ZeroCopyHttpOutputMessage; +import org.springframework.http.support.JettyHeadersAdapter; +import org.springframework.lang.Nullable; + +/** + * Adapt an Eclipse Jetty {@link Response} to an {@link org.springframework.http.server.ServerHttpResponse}. + * + * @author Greg Wilkins + * @author Lachlan Roberts + * @since 6.2 + */ +class JettyCoreServerHttpResponse extends AbstractServerHttpResponse implements ZeroCopyHttpOutputMessage { + + private final Response response; + + + public JettyCoreServerHttpResponse(Response response, JettyDataBufferFactory dataBufferFactory) { + super(dataBufferFactory, new HttpHeaders(new JettyHeadersAdapter(response.getHeaders()))); + this.response = response; + + // remove all existing cookies from the response and add them to the cookie map, to be added back later + for (ListIterator i = this.response.getHeaders().listIterator(); i.hasNext(); ) { + HttpField f = i.next(); + if (f instanceof HttpCookieUtils.SetCookieHttpField setCookieHttpField) { + HttpCookie httpCookie = setCookieHttpField.getHttpCookie(); + ResponseCookie responseCookie = ResponseCookie.from(httpCookie.getName(), httpCookie.getValue()) + .httpOnly(httpCookie.isHttpOnly()) + .domain(httpCookie.getDomain()) + .maxAge(httpCookie.getMaxAge()) + .sameSite(httpCookie.getSameSite().name()) + .secure(httpCookie.isSecure()) + .partitioned(httpCookie.isPartitioned()) + .build(); + this.addCookie(responseCookie); + i.remove(); + } + } + } + + + @Override + protected Mono writeWithInternal(Publisher body) { + return Flux.from(body) + .concatMap(this::sendDataBuffer) + .then(); + } + + @Override + protected Mono writeAndFlushWithInternal(Publisher> body) { + return Flux.from(body).concatMap(this::writeWithInternal).then(); + } + + @Override + protected void applyStatusCode() { + HttpStatusCode status = getStatusCode(); + this.response.setStatus(status == null ? 0 : status.value()); + } + + @Override + protected void applyHeaders() { + } + + @Override + protected void applyCookies() { + this.getCookies().values().stream() + .flatMap(List::stream) + .forEach(cookie -> Response.addCookie(this.response, new ResponseHttpCookie(cookie))); + } + + @Override + public Mono writeWith(Path file, long position, long count) { + Callback.Completable callback = new Callback.Completable(); + Mono mono = Mono.fromFuture(callback); + try { + Content.copy(Content.Source.from(null, file, position, count), this.response, callback); + } + catch (Throwable th) { + callback.failed(th); + } + return doCommit(() -> mono); + } + + private Mono sendDataBuffer(DataBuffer dataBuffer) { + return Mono.defer(() -> { + DataBuffer.ByteBufferIterator byteBufferIterator = dataBuffer.readableByteBuffers(); + Callback.Completable callback = new Callback.Completable(); + new IteratingCallback() { + @Override + protected Action process() { + if (!byteBufferIterator.hasNext()) { + return Action.SUCCEEDED; + } + response.write(false, byteBufferIterator.next(), this); + return Action.SCHEDULED; + } + + @Override + protected void onCompleteSuccess() { + byteBufferIterator.close(); + DataBufferUtils.release(dataBuffer); + callback.complete(null); + } + + @Override + protected void onCompleteFailure(Throwable cause) { + byteBufferIterator.close(); + DataBufferUtils.release(dataBuffer); + callback.failed(cause); + } + }.iterate(); + + return Mono.fromFuture(callback); + }); + } + + @SuppressWarnings("unchecked") + @Override + public T getNativeResponse() { + return (T) this.response; + } + + + private static class ResponseHttpCookie implements org.eclipse.jetty.http.HttpCookie { + + private final ResponseCookie responseCookie; + + + ResponseHttpCookie(ResponseCookie responseCookie) { + this.responseCookie = responseCookie; + } + + + @Override + public String getName() { + return this.responseCookie.getName(); + } + + @Override + public String getValue() { + return this.responseCookie.getValue(); + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public long getMaxAge() { + return this.responseCookie.getMaxAge().toSeconds(); + } + + @Override + @Nullable + public String getComment() { + return null; + } + + @Override + @Nullable + public String getDomain() { + return this.responseCookie.getDomain(); + } + + @Override + @Nullable + public String getPath() { + return this.responseCookie.getPath(); + } + + @Override + public boolean isSecure() { + return this.responseCookie.isSecure(); + } + + @Nullable + @Override + public SameSite getSameSite() { + // Adding non-null return site breaks tests. + return null; + } + + @Override + public boolean isHttpOnly() { + return this.responseCookie.isHttpOnly(); + } + + @Override + public boolean isPartitioned() { + return this.responseCookie.isPartitioned(); + } + + @Override + public Map getAttributes() { + return Collections.emptyMap(); + } + + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorNetty2ServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorNetty2ServerHttpResponse.java index 598ed56d0adc..0e8c6c68b1d9 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorNetty2ServerHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorNetty2ServerHttpResponse.java @@ -112,6 +112,7 @@ protected void applyCookies() { for (ResponseCookie httpCookie : getCookies().get(name)) { Long maxAge = (!httpCookie.getMaxAge().isNegative()) ? httpCookie.getMaxAge().getSeconds() : null; HttpSetCookie.SameSite sameSite = (httpCookie.getSameSite() != null) ? HttpSetCookie.SameSite.valueOf(httpCookie.getSameSite()) : null; + // TODO: support Partitioned attribute when available in Netty 5 API DefaultHttpSetCookie cookie = new DefaultHttpSetCookie(name, httpCookie.getValue(), httpCookie.getPath(), httpCookie.getDomain(), null, maxAge, sameSite, false, httpCookie.isSecure(), httpCookie.isHttpOnly()); this.response.addCookie(cookie); 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 b939eee45ac9..b136e9bbb7b0 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 @@ -65,7 +65,8 @@ class ReactorServerHttpRequest extends AbstractServerHttpRequest { public ReactorServerHttpRequest(HttpServerRequest request, NettyDataBufferFactory bufferFactory) throws URISyntaxException { - super(HttpMethod.valueOf(request.method().name()), ReactorUriHelper.createUri(request), "", + super(HttpMethod.valueOf(request.method().name()), + ReactorUriHelper.createUri(request), request.forwardedPrefix(), new Netty4HeadersAdapter(request.requestHeaders())); Assert.notNull(bufferFactory, "DataBufferFactory must not be null"); this.request = request; 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 5e3de60f561c..359629ff75d0 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 @@ -121,6 +121,7 @@ protected void applyCookies() { } cookie.setSecure(httpCookie.isSecure()); cookie.setHttpOnly(httpCookie.isHttpOnly()); + cookie.setPartitioned(httpCookie.isPartitioned()); if (httpCookie.getSameSite() != null) { cookie.setSameSite(CookieHeaderNames.SameSite.valueOf(httpCookie.getSameSite())); } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorUriHelper.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorUriHelper.java index 2207f9e76ccc..b2544cc8483f 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorUriHelper.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorUriHelper.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +24,7 @@ import org.springframework.util.Assert; /** - * Helper class for creating a {@link URI} from a reactor {@link HttpServerRequest}. + * Helper class to create {@link URI} from a Reactor Netty request. * * @author Arjen Poutsma * @since 6.0.8 @@ -48,6 +48,14 @@ public static URI createUri(HttpServerRequest request) throws URISyntaxException builder.append(port); } + // Reactor Netty has config whether to extract and apply forwarded headers. + // We apply the prefix manually as it affects the contextPath too. + + String prefix = request.forwardedPrefix(); + if (prefix != null && !prefix.isEmpty()) { + builder.append(prefix); + } + appendRequestUri(request, builder); return new URI(builder.toString()); diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequestDecorator.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequestDecorator.java index 56592303badb..fbeacac71896 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequestDecorator.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequestDecorator.java @@ -18,6 +18,7 @@ import java.net.InetSocketAddress; import java.net.URI; +import java.util.Map; import reactor.core.publisher.Flux; @@ -70,6 +71,11 @@ public URI getURI() { return getDelegate().getURI(); } + @Override + public Map getAttributes() { + return getDelegate().getAttributes(); + } + @Override public RequestPath getPath() { return getDelegate().getPath(); 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 500d644e2f12..093076710b19 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +148,7 @@ private String getServletPath(ServletConfig config) { throw new IllegalArgumentException("Expected a single Servlet mapping: " + "either the default Servlet mapping (i.e. '/'), " + - "or a path based mapping (e.g. '/*', '/foo/*'). " + + "or a path based mapping (for example, '/*', '/foo/*'). " + "Actual mappings: " + mappings + " for Servlet '" + name + "'"); } @@ -237,7 +237,7 @@ private static void runIfAsyncNotComplete(AsyncContext asyncContext, AtomicBoole } catch (IllegalStateException ex) { // Ignore: AsyncContext recycled and should not be used - // e.g. TIMEOUT_LISTENER (above) may have completed the AsyncContext + // for example, TIMEOUT_LISTENER (above) may have completed the AsyncContext } } 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 9d2602415f5a..ed5c52b112ee 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 @@ -51,6 +51,7 @@ import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; /** * Adapt {@link ServerHttpRequest} to the Servlet {@link HttpServletRequest}. @@ -90,8 +91,8 @@ public ServletServerHttpRequest(MultiValueMap headers, HttpServl AsyncContext asyncContext, String servletPath, DataBufferFactory bufferFactory, int bufferSize) throws IOException, URISyntaxException { - super(HttpMethod.valueOf(request.getMethod()), initUri(request), request.getContextPath() + servletPath, - initHeaders(headers, request)); + super(HttpMethod.valueOf(request.getMethod()), initUri(request), + request.getContextPath() + servletPath, initHeaders(headers, request)); Assert.notNull(bufferFactory, "'bufferFactory' must not be null"); Assert.isTrue(bufferSize > 0, "'bufferSize' must be greater than 0"); @@ -121,16 +122,45 @@ private static MultiValueMap createDefaultHttpHeaders(HttpServle return headers; } - private static URI initUri(HttpServletRequest request) throws URISyntaxException { - Assert.notNull(request, "'request' must not be null"); - StringBuffer url = request.getRequestURL(); - String query = request.getQueryString(); - if (StringUtils.hasText(query)) { - url.append('?').append(query); + @SuppressWarnings("JavaExistingMethodCanBeUsed") + private static URI initUri(HttpServletRequest servletRequest) { + Assert.notNull(servletRequest, "'request' must not be null"); + String urlString = null; + String query = null; + boolean hasQuery = false; + try { + StringBuffer requestURL = servletRequest.getRequestURL(); + query = servletRequest.getQueryString(); + hasQuery = StringUtils.hasText(query); + if (hasQuery) { + requestURL.append('?').append(query); + } + urlString = requestURL.toString(); + return new URI(urlString); + } + catch (URISyntaxException ex) { + if (hasQuery) { + try { + // Maybe malformed query, try to parse and encode it + query = UriComponentsBuilder.fromUriString("?" + query).build().toUri().getRawQuery(); + return new URI(servletRequest.getRequestURL().toString() + "?" + query); + } + catch (URISyntaxException ex2) { + try { + // Try leaving it out + return new URI(servletRequest.getRequestURL().toString()); + } + catch (URISyntaxException ex3) { + // ignore + } + } + } + throw new IllegalStateException( + "Could not resolve HttpServletRequest as URI: " + urlString, ex); } - return new URI(url.toString()); } + @SuppressWarnings("NullAway") private static MultiValueMap initHeaders( MultiValueMap headerValues, HttpServletRequest request) { 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 98d823bc0cab..f77b09569a8d 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 @@ -39,6 +39,7 @@ import org.springframework.http.ResponseCookie; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; /** * Adapt {@link ServerHttpResponse} to the Servlet {@link HttpServletResponse}. @@ -49,6 +50,8 @@ */ class ServletServerHttpResponse extends AbstractListenerServerHttpResponse { + private static final boolean IS_SERVLET61 = ReflectionUtils.findField(HttpServletResponse.class, "SC_PERMANENT_REDIRECT") != null; + private final HttpServletResponse response; private final ServletOutputStream outputStream; @@ -182,6 +185,14 @@ protected void applyCookies() { } cookie.setSecure(httpCookie.isSecure()); cookie.setHttpOnly(httpCookie.isHttpOnly()); + if (httpCookie.isPartitioned()) { + if (IS_SERVLET61) { + cookie.setAttribute("Partitioned", ""); + } + else { + cookie.setAttribute("Partitioned", "true"); + } + } this.response.addCookie(cookie); } } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java index 8c58eb159d8c..4517bc413247 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java @@ -66,25 +66,27 @@ public DataBufferFactory getDataBufferFactory() { @Override public void handleRequest(HttpServerExchange exchange) { - UndertowServerHttpRequest request = null; - try { - request = new UndertowServerHttpRequest(exchange, getDataBufferFactory()); - } - catch (URISyntaxException ex) { - if (logger.isWarnEnabled()) { - logger.debug("Failed to get request URI: " + ex.getMessage()); + exchange.dispatch(() -> { + UndertowServerHttpRequest request = null; + try { + request = new UndertowServerHttpRequest(exchange, getDataBufferFactory()); } - exchange.setStatusCode(400); - return; - } - ServerHttpResponse response = new UndertowServerHttpResponse(exchange, getDataBufferFactory(), request); + catch (URISyntaxException ex) { + if (logger.isWarnEnabled()) { + logger.debug("Failed to get request URI: " + ex.getMessage()); + } + exchange.setStatusCode(400); + return; + } + ServerHttpResponse response = new UndertowServerHttpResponse(exchange, getDataBufferFactory(), request); - if (request.getMethod() == HttpMethod.HEAD) { - response = new HttpHeadResponseDecorator(response); - } + if (request.getMethod() == HttpMethod.HEAD) { + response = new HttpHeadResponseDecorator(response); + } - HandlerResultSubscriber resultSubscriber = new HandlerResultSubscriber(exchange, request); - this.httpHandler.handle(request, response).subscribe(resultSubscriber); + HandlerResultSubscriber resultSubscriber = new HandlerResultSubscriber(exchange, request); + this.httpHandler.handle(request, response).subscribe(resultSubscriber); + }); } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java index ef8f33070c8b..9de01ff320ef 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java @@ -123,6 +123,7 @@ protected void applyCookies() { } cookie.setSecure(httpCookie.isSecure()); cookie.setHttpOnly(httpCookie.isHttpOnly()); + // TODO: add "Partitioned" attribute when Undertow supports it cookie.setSameSiteMode(httpCookie.getSameSite()); this.exchange.setResponseCookie(cookie); } 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 63ac63dd3557..da41ee1450fa 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 @@ -36,6 +36,7 @@ * @author Rossen Stoyanchev * @since 5.0 */ +@SuppressWarnings("NullAway") class WriteResultPublisher implements Publisher { /** diff --git a/spring-web/src/main/java/org/springframework/http/support/HttpComponentsHeadersAdapter.java b/spring-web/src/main/java/org/springframework/http/support/HttpComponentsHeadersAdapter.java index 8b584c52c7ed..7bb2682aec1b 100644 --- a/spring-web/src/main/java/org/springframework/http/support/HttpComponentsHeadersAdapter.java +++ b/spring-web/src/main/java/org/springframework/http/support/HttpComponentsHeadersAdapter.java @@ -49,6 +49,7 @@ public final class HttpComponentsHeadersAdapter implements MultiValueMap headerNames; + /** * Create a new {@code HttpComponentsHeadersAdapter} based on the given * {@code HttpMessage}. diff --git a/spring-web/src/main/java/org/springframework/http/support/JettyHeadersAdapter.java b/spring-web/src/main/java/org/springframework/http/support/JettyHeadersAdapter.java index f59e507bb624..aa98ae422375 100644 --- a/spring-web/src/main/java/org/springframework/http/support/JettyHeadersAdapter.java +++ b/spring-web/src/main/java/org/springframework/http/support/JettyHeadersAdapter.java @@ -17,9 +17,11 @@ package org.springframework.http.support; import java.util.AbstractSet; +import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; import java.util.List; +import java.util.ListIterator; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -46,6 +48,9 @@ public final class JettyHeadersAdapter implements MultiValueMap private final HttpFields headers; + @Nullable + private final HttpFields.Mutable mutable; + /** * Creates a new {@code JettyHeadersAdapter} based on the given @@ -55,6 +60,7 @@ public final class JettyHeadersAdapter implements MultiValueMap public JettyHeadersAdapter(HttpFields headers) { Assert.notNull(headers, "Headers must not be null"); this.headers = headers; + this.mutable = headers instanceof HttpFields.Mutable m ? m : null; } @@ -122,22 +128,36 @@ public boolean isEmpty() { @Override public boolean containsKey(Object key) { - return (key instanceof String headerName && this.headers.contains(headerName)); + return (key instanceof String name && this.headers.contains(name)); } @Override public boolean containsValue(Object value) { - return (value instanceof String searchString && - this.headers.stream().anyMatch(field -> field.contains(searchString))); + if (value instanceof String searchString) { + for (HttpField field : this.headers) { + if (field.contains(searchString)) { + return true; + } + } + } + return false; } @Nullable @Override public List get(Object key) { - if (containsKey(key)) { - return this.headers.getValuesList((String) key); + List list = null; + if (key instanceof String name) { + for (HttpField f : this.headers) { + if (f.is(name)) { + if (list == null) { + list = new ArrayList<>(); + } + list.add(f.getValue()); + } + } } - return null; + return list; } @Nullable @@ -145,7 +165,21 @@ public List get(Object key) { public List put(String key, List value) { HttpFields.Mutable mutableHttpFields = mutableFields(); List oldValues = get(key); - mutableHttpFields.put(key, value); + + if (oldValues == null) { + switch (value.size()) { + case 0 -> {} + case 1 -> mutableHttpFields.add(key, value.get(0)); + default -> mutableHttpFields.add(key, value); + } + } + else { + switch (value.size()) { + case 0 -> mutableHttpFields.remove(key); + case 1 -> mutableHttpFields.put(key, value.get(0)); + default -> mutableHttpFields.put(key, value); + } + } return oldValues; } @@ -153,12 +187,20 @@ public List put(String key, List value) { @Override public List remove(Object key) { HttpFields.Mutable mutableHttpFields = mutableFields(); + List list = null; if (key instanceof String name) { - List oldValues = get(key); - mutableHttpFields.remove(name); - return oldValues; + for (ListIterator i = mutableHttpFields.listIterator(); i.hasNext(); ) { + HttpField f = i.next(); + if (f.is(name)) { + if (list == null) { + list = new ArrayList<>(); + } + list.add(f.getValue()); + i.remove(); + } + } } - return null; + return list; } @Override @@ -190,6 +232,7 @@ public Set>> entrySet() { public Iterator>> iterator() { return new EntryIterator(); } + @Override public int size() { return headers.getFieldNamesCollection().size(); @@ -198,16 +241,12 @@ public int size() { } private HttpFields.Mutable mutableFields() { - if (this.headers instanceof HttpFields.Mutable mutableHttpFields) { - return mutableHttpFields; - } - else { + if (this.mutable == null) { throw new IllegalStateException("Immutable headers"); } + return this.mutable; } - - @Override public String toString() { return HttpHeaders.formatHeaders(this); diff --git a/spring-web/src/main/java/org/springframework/web/ErrorResponse.java b/spring-web/src/main/java/org/springframework/web/ErrorResponse.java index b47ba2f0d464..c077b83d9d41 100644 --- a/spring-web/src/main/java/org/springframework/web/ErrorResponse.java +++ b/spring-web/src/main/java/org/springframework/web/ErrorResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +35,7 @@ * interface and a convenient base class for other exceptions to use. * *

        {@code ErrorResponse} is supported as a return value from - * {@code @ExceptionHandler} methods that render directly to the response, e.g. + * {@code @ExceptionHandler} methods that render directly to the response, for example, * by being marked {@code @ResponseBody}, or declared in an * {@code @RestController} or {@code RestControllerAdvice} class. * @@ -62,7 +62,7 @@ default HttpHeaders getHeaders() { * {@link ProblemDetail} whose {@link ProblemDetail#getStatus() status} * should match the response status. *

        Note: The returned {@code ProblemDetail} may be - * updated before the response is rendered, e.g. via + * updated before the response is rendered, for example, via * {@link #updateAndGetBody(MessageSource, Locale)}. Therefore, implementing * methods should use an instance field, and should not re-create the * {@code ProblemDetail} on every call, nor use a static variable. @@ -107,7 +107,7 @@ default String getDetailMessageCode() { * Return arguments to use along with a {@link #getDetailMessageCode() * message code} to resolve the problem "detail" for this exception * through a {@link MessageSource}. The arguments are expanded - * into placeholders of the message value, e.g. "Invalid content type {0}". + * into placeholders of the message value, for example, "Invalid content type {0}". */ @Nullable default Object[] getDetailMessageArguments() { @@ -180,7 +180,7 @@ static String getDefaultTitleMessageCode(Class exceptionType) { /** * Build a message code for the "detail" field, for the given exception type. * @param exceptionType the exception type associated with the problem - * @param suffix an optional suffix, e.g. for exceptions that may have multiple + * @param suffix an optional suffix, for example, for exceptions that may have multiple * error message with different arguments * @return {@code "problemDetail."} followed by the fully qualified * {@link Class#getName() class name} and an optional suffix @@ -338,4 +338,24 @@ default ErrorResponse build(@Nullable MessageSource messageSource, Locale locale } + + /** + * Callback to perform an action before an RFC-9457 {@link ProblemDetail} + * response is rendered. + * + * @author Rossen Stoyanchev + * @since 6.2 + */ + interface Interceptor { + + /** + * Handle the given {@code ProblemDetail} that's going to be rendered, + * and the {@code ErrorResponse} it originates from, if applicable. + * @param detail the {@code ProblemDetail} to be rendered + * @param errorResponse the {@code ErrorResponse}, or {@code null} if there isn't one + */ + void handleError(ProblemDetail detail, @Nullable ErrorResponse errorResponse); + + } + } diff --git a/spring-web/src/main/java/org/springframework/web/HttpRequestMethodNotSupportedException.java b/spring-web/src/main/java/org/springframework/web/HttpRequestMethodNotSupportedException.java index cf667bae3e40..3d78325f90b6 100644 --- a/spring-web/src/main/java/org/springframework/web/HttpRequestMethodNotSupportedException.java +++ b/spring-web/src/main/java/org/springframework/web/HttpRequestMethodNotSupportedException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +17,6 @@ package org.springframework.web; import java.util.Collection; -import java.util.LinkedHashSet; import java.util.Set; import jakarta.servlet.ServletException; @@ -28,6 +27,7 @@ import org.springframework.http.HttpStatusCode; import org.springframework.http.ProblemDetail; import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; @@ -107,7 +107,7 @@ public Set getSupportedHttpMethods() { if (this.supportedMethods == null) { return null; } - Set supportedMethods = new LinkedHashSet<>(this.supportedMethods.length); + Set supportedMethods = CollectionUtils.newLinkedHashSet(this.supportedMethods.length); for (String value : this.supportedMethods) { HttpMethod method = HttpMethod.valueOf(value); supportedMethods.add(method); diff --git a/spring-web/src/main/java/org/springframework/web/WebApplicationInitializer.java b/spring-web/src/main/java/org/springframework/web/WebApplicationInitializer.java index 6ee0811cb8ed..d4ad31fc4fb5 100644 --- a/spring-web/src/main/java/org/springframework/web/WebApplicationInitializer.java +++ b/spring-web/src/main/java/org/springframework/web/WebApplicationInitializer.java @@ -87,7 +87,7 @@ *

        Most major Spring Web components have been updated to support this style of * registration. You'll find that {@code DispatcherServlet}, {@code FrameworkServlet}, * {@code ContextLoaderListener} and {@code DelegatingFilterProxy} all now support - * constructor arguments. Even if a component (e.g. non-Spring, other third party) has not + * constructor arguments. Even if a component (for example, non-Spring, other third party) has not * been specifically updated for use within {@code WebApplicationInitializers}, they still * may be used in any case. The {@code ServletContext} API allows for setting init-params, * context-params, etc programmatically. diff --git a/spring-web/src/main/java/org/springframework/web/accept/AbstractMappingContentNegotiationStrategy.java b/spring-web/src/main/java/org/springframework/web/accept/AbstractMappingContentNegotiationStrategy.java index 66aef505e6df..89f01c57b1b3 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/AbstractMappingContentNegotiationStrategy.java +++ b/spring-web/src/main/java/org/springframework/web/accept/AbstractMappingContentNegotiationStrategy.java @@ -35,13 +35,13 @@ * Base class for {@code ContentNegotiationStrategy} implementations with the * steps to resolve a request to media types. * - *

        First a key (e.g. "json", "pdf") must be extracted from the request (e.g. + *

        First a key (for example, "json", "pdf") must be extracted from the request (for example, * file extension, query param). The key must then be resolved to media type(s) * through the base class {@link MappingMediaTypeFileExtensionResolver} which * stores such mappings. * *

        The method {@link #handleNoMatch} allow subclasses to plug in additional - * ways of looking up media types (e.g. through the Java Activation framework, + * ways of looking up media types (for example, through the Java Activation framework, * or {@link jakarta.servlet.ServletContext#getMimeType}). Media types resolved * via base classes are then added to the base class * {@link MappingMediaTypeFileExtensionResolver}, i.e. cached for new lookups. @@ -69,7 +69,7 @@ public AbstractMappingContentNegotiationStrategy(@Nullable MapBy default this is set to {@code false}. */ public void setUseRegisteredExtensionsOnly(boolean useRegisteredExtensionsOnly) { diff --git a/spring-web/src/main/java/org/springframework/web/accept/ContentNegotiationManagerFactoryBean.java b/spring-web/src/main/java/org/springframework/web/accept/ContentNegotiationManagerFactoryBean.java index e0c75e28e026..91bc8b3a9c24 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/ContentNegotiationManagerFactoryBean.java +++ b/spring-web/src/main/java/org/springframework/web/accept/ContentNegotiationManagerFactoryBean.java @@ -188,7 +188,7 @@ public void setFavorPathExtension(boolean favorPathExtension) { *

        Note: Mappings registered here may be accessed via * {@link ContentNegotiationManager#getMediaTypeMappings()} and may be used * not only in the parameter and path extension strategies. For example, - * with the Spring MVC config, e.g. {@code @EnableWebMvc} or + * with the Spring MVC config, for example, {@code @EnableWebMvc} or * {@code }, the media type mappings are also plugged * in to: *

          @@ -254,7 +254,7 @@ public void setUseJaf(boolean useJaf) { * When {@link #setFavorPathExtension favorPathExtension} or * {@link #setFavorParameter(boolean)} is set, this property determines * whether to use only registered {@code MediaType} mappings or to allow - * dynamic resolution, e.g. via {@link MediaTypeFactory}. + * dynamic resolution, for example, via {@link MediaTypeFactory}. *

          By default this is not set in which case dynamic resolution is on. */ public void setUseRegisteredExtensionsOnly(boolean useRegisteredExtensionsOnly) { diff --git a/spring-web/src/main/java/org/springframework/web/accept/MappingMediaTypeFileExtensionResolver.java b/spring-web/src/main/java/org/springframework/web/accept/MappingMediaTypeFileExtensionResolver.java index 3ad1f7a4bbb1..7c8f705da577 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/MappingMediaTypeFileExtensionResolver.java +++ b/spring-web/src/main/java/org/springframework/web/accept/MappingMediaTypeFileExtensionResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.util.ArrayList; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -29,6 +28,7 @@ import org.springframework.http.MediaType; import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; /** * An implementation of {@code MediaTypeFileExtensionResolver} that maintains @@ -55,7 +55,7 @@ public class MappingMediaTypeFileExtensionResolver implements MediaTypeFileExten */ public MappingMediaTypeFileExtensionResolver(@Nullable Map mediaTypes) { if (mediaTypes != null) { - Set allFileExtensions = new HashSet<>(mediaTypes.size()); + Set allFileExtensions = CollectionUtils.newHashSet(mediaTypes.size()); mediaTypes.forEach((extension, mediaType) -> { String lowerCaseExtension = extension.toLowerCase(Locale.ROOT); this.mediaTypes.put(lowerCaseExtension, mediaType); diff --git a/spring-web/src/main/java/org/springframework/web/bind/MethodArgumentNotValidException.java b/spring-web/src/main/java/org/springframework/web/bind/MethodArgumentNotValidException.java index c8487e6f802f..442c9ac30d1b 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MethodArgumentNotValidException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MethodArgumentNotValidException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,16 +16,13 @@ package org.springframework.web.bind; -import java.util.List; import java.util.Locale; -import java.util.Map; import org.springframework.context.MessageSource; import org.springframework.core.MethodParameter; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.ProblemDetail; -import org.springframework.lang.Nullable; import org.springframework.validation.BindException; import org.springframework.validation.BindingResult; import org.springframework.validation.ObjectError; @@ -92,45 +89,6 @@ public Object[] getDetailMessageArguments() { BindErrorUtils.resolveAndJoin(getFieldErrors())}; } - /** - * Convert each given {@link ObjectError} to a String. - * @since 6.0 - * @deprecated in favor of using {@link BindErrorUtils} and - * {@link #getAllErrors()}, to be removed in 6.2 - */ - @Deprecated(since = "6.1", forRemoval = true) - public static List errorsToStringList(List errors) { - return BindErrorUtils.resolve(errors).values().stream().toList(); - } - - /** - * Convert each given {@link ObjectError} to a String, and use a - * {@link MessageSource} to resolve each error. - * @since 6.0 - * @deprecated in favor of {@link BindErrorUtils}, to be removed in 6.2 - */ - @Deprecated(since = "6.1", forRemoval = true) - public static List errorsToStringList( - List errors, @Nullable MessageSource messageSource, Locale locale) { - - return (messageSource != null ? - BindErrorUtils.resolve(errors, messageSource, locale).values().stream().toList() : - BindErrorUtils.resolve(errors).values().stream().toList()); - } - - /** - * Resolve global and field errors to messages with the given - * {@link MessageSource} and {@link Locale}. - * @return a Map with errors as keys and resolved messages as values - * @since 6.0.3 - * @deprecated in favor of using {@link BindErrorUtils} and - * {@link #getAllErrors()}, to be removed in 6.2 - */ - @Deprecated(since = "6.1", forRemoval = true) - public Map resolveErrorMessages(MessageSource messageSource, Locale locale) { - return BindErrorUtils.resolve(getAllErrors(), messageSource, locale); - } - @Override public String getMessage() { StringBuilder sb = new StringBuilder("Validation failed for argument [") 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 index 8770b3d64494..bc606373b8f0 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MissingRequestValueException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MissingRequestValueException.java @@ -64,7 +64,7 @@ protected MissingRequestValueException(String msg, boolean missingAfterConversio /** - * Whether the request value was present but converted to {@code null}, e.g. via + * Whether the request value was present but converted to {@code null}, for example, via * {@code org.springframework.core.convert.support.IdToEntityConverter}. */ public boolean isMissingAfterConversion() { diff --git a/spring-web/src/main/java/org/springframework/web/bind/ServletRequestParameterPropertyValues.java b/spring-web/src/main/java/org/springframework/web/bind/ServletRequestParameterPropertyValues.java index f127baee8492..e07bc6c73dbb 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/ServletRequestParameterPropertyValues.java +++ b/spring-web/src/main/java/org/springframework/web/bind/ServletRequestParameterPropertyValues.java @@ -71,7 +71,7 @@ public ServletRequestParameterPropertyValues(ServletRequest request, @Nullable S * @param request the HTTP request * @param prefix the prefix for parameters (the full prefix will * consist of this plus the separator) - * @param prefixSeparator separator delimiting prefix (e.g. "spring") + * @param prefixSeparator separator delimiting prefix (for example, "spring") * and the rest of the parameter name ("param1", "param2") */ public ServletRequestParameterPropertyValues( diff --git a/spring-web/src/main/java/org/springframework/web/bind/WebDataBinder.java b/spring-web/src/main/java/org/springframework/web/bind/WebDataBinder.java index ada9ca0d794d..cb907effab7c 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/WebDataBinder.java +++ b/spring-web/src/main/java/org/springframework/web/bind/WebDataBinder.java @@ -66,7 +66,7 @@ public class WebDataBinder extends DataBinder { /** * Default prefix that field marker parameters start with, followed by the field - * name: e.g. "_subscribeToNewsletter" for a field "subscribeToNewsletter". + * name: for example, "_subscribeToNewsletter" for a field "subscribeToNewsletter". *

          Such a marker parameter indicates that the field was visible, that is, * existed in the form that caused the submission. If no corresponding field * value parameter was found, the field will be reset. The value of the field @@ -78,7 +78,7 @@ public class WebDataBinder extends DataBinder { /** * Default prefix that field default parameters start with, followed by the field - * name: e.g. "!subscribeToNewsletter" for a field "subscribeToNewsletter". + * name: for example, "!subscribeToNewsletter" for a field "subscribeToNewsletter". *

          Default parameters differ from field markers in that they provide a default * value instead of an empty value. * @see #setFieldDefaultPrefix @@ -120,7 +120,7 @@ public WebDataBinder(@Nullable Object target, String objectName) { * empty fields, having "prefix + field" as name. Such a marker parameter is * checked by existence: You can send any value for it, for example "visible". * This is particularly useful for HTML checkboxes and select options. - *

          Default is "_", for "_FIELD" parameters (e.g. "_subscribeToNewsletter"). + *

          Default is "_", for "_FIELD" parameters (for example, "_subscribeToNewsletter"). * Set this to null if you want to turn off the empty field check completely. *

          HTML checkboxes only send a value when they're checked, so it is not * possible to detect that a formerly checked box has just been unchecked, @@ -152,7 +152,7 @@ public String getFieldMarkerPrefix() { * Specify a prefix that can be used for parameters that indicate default * value fields, having "prefix + field" as name. The value of the default * field is used when the field is not provided. - *

          Default is "!", for "!FIELD" parameters (e.g. "!subscribeToNewsletter"). + *

          Default is "!", for "!FIELD" parameters (for example, "!subscribeToNewsletter"). * Set this to null if you want to turn off the field defaults completely. *

          HTML checkboxes only send a value when they're checked, so it is not * possible to detect that a formerly checked box has just been unchecked, diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/CrossOrigin.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/CrossOrigin.java index 7ee9ce421bc2..33e4afe2384d 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/CrossOrigin.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/CrossOrigin.java @@ -38,7 +38,7 @@ * {@link CorsConfiguration#applyPermitDefaultValues()}. * *

          The rules for combining global and local configuration are generally - * additive -- e.g. all global and all local origins. For those attributes + * additive -- for example, all global and all local origins. For those attributes * where only a single value can be accepted such as {@code allowCredentials} * and {@code maxAge}, the local overrides the global value. * See {@link CorsConfiguration#combine(CorsConfiguration)} for more details. diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/ExceptionHandler.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/ExceptionHandler.java index bb80146627c7..8fcd4f337a90 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/ExceptionHandler.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/ExceptionHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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 org.springframework.aot.hint.annotation.Reflective; +import org.springframework.core.annotation.AliasFor; /** * Annotation for handling exceptions in specific handler classes and/or @@ -39,7 +40,7 @@ * cause within a wrapper exception. As of 5.3, any cause level is being * exposed, whereas previously only an immediate cause was considered. *

        • Request and/or response objects (typically from the Servlet API). - * You may choose any specific request/response type, e.g. + * You may choose any specific request/response type, for example, * {@link jakarta.servlet.ServletRequest} / {@link jakarta.servlet.http.HttpServletRequest}. *
        • Session object: typically {@link jakarta.servlet.http.HttpSession}. * An argument of this type will enforce the presence of a corresponding session. @@ -101,6 +102,7 @@ * * @author Arjen Poutsma * @author Juergen Hoeller + * @author Brian Clozel * @since 3.0 * @see ControllerAdvice * @see org.springframework.web.context.request.WebRequest @@ -111,10 +113,25 @@ @Reflective(ExceptionHandlerReflectiveProcessor.class) public @interface ExceptionHandler { + /** + * Exceptions handled by the annotated method. + *

          This is an alias for {@link #exception}. + */ + @AliasFor("exception") + Class[] value() default {}; + /** * Exceptions handled by the annotated method. If empty, will default to any * exceptions listed in the method argument list. + * @since 6.2 */ - Class[] value() default {}; + @AliasFor("value") + Class[] exception() default {}; + + /** + * Media Types that can be produced by the annotated method. + * @since 6.2 + */ + String[] produces() default {}; } diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/MatrixVariable.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/MatrixVariable.java index 12ae0c02e005..6f830c1eb131 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/MatrixVariable.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/MatrixVariable.java @@ -63,7 +63,7 @@ /** * The name of the URI path variable where the matrix variable is located, - * if necessary for disambiguation (e.g. a matrix variable with the same + * if necessary for disambiguation (for example, a matrix variable with the same * name present in more than one path segment). */ String pathVar() default ValueConstants.DEFAULT_NONE; diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/ModelAttribute.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/ModelAttribute.java index fa3893153850..4019c20dcceb 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/ModelAttribute.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/ModelAttribute.java @@ -82,7 +82,7 @@ *

          The default model attribute name is inferred from the declared * attribute type (i.e. the method parameter type or method return type), * based on the non-qualified class name: - * e.g. "orderAddress" for class "mypackage.OrderAddress", + * for example, "orderAddress" for class "mypackage.OrderAddress", * or "orderAddressList" for "List<mypackage.OrderAddress>". * @since 4.3 */ diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/PathVariable.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/PathVariable.java index cee9fe2485c2..1856378ce911 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/PathVariable.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/PathVariable.java @@ -60,7 +60,7 @@ *

          Defaults to {@code true}, leading to an exception being thrown if the path * variable is missing in the incoming request. Switch this to {@code false} if * you prefer a {@code null} or Java 8 {@code java.util.Optional} in this case. - * e.g. on a {@code ModelAttribute} method which serves for different requests. + * for example, on a {@code ModelAttribute} method which serves for different requests. * @since 4.3.3 */ boolean required() default true; diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java index 0bff16d474fb..d01491b34697 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java @@ -61,7 +61,7 @@ * mapping will be used. This also applies to composed {@code @RequestMapping} * annotations such as {@code @GetMapping}, {@code @PostMapping}, etc. * - *

          NOTE: When using controller interfaces (e.g. for AOP proxying), + *

          NOTE: When using controller interfaces (for example, for AOP proxying), * make sure to consistently put all your mapping annotations — such * as {@code @RequestMapping} and {@code @SessionAttributes} — on * the controller interface rather than on the implementation class. @@ -105,10 +105,10 @@ /** * The path mapping URIs — for example, {@code "/profile"}. - *

          Ant-style path patterns are also supported (e.g. {@code "/profile/**"}). - * At the method level, relative paths (e.g. {@code "edit"}) are supported + *

          Ant-style path patterns are also supported (for example, {@code "/profile/**"}). + * At the method level, relative paths (for example, {@code "edit"}) are supported * within the primary mapping expressed at the type level. - * Path mapping URIs may contain placeholders (e.g. "/${profile_path}"). + * Path mapping URIs may contain placeholders (for example, "/${profile_path}"). *

          Supported at the type level as well as at the method level! * When used at the type level, all method-level mappings inherit * this primary mapping, narrowing it for a specific handler method. @@ -202,7 +202,7 @@ * produces = MediaType.TEXT_PLAIN_VALUE * produces = "text/plain;charset=UTF-8" * - *

          If a declared media type contains a parameter (e.g. "charset=UTF-8", + *

          If a declared media type contains a parameter (for example, "charset=UTF-8", * "type=feed", "type=entry") and if a compatible media type from the request * has that parameter too, then the parameter values must match. Otherwise, * if the media type from the request does not contain the parameter, it is diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestPart.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestPart.java index e7c576b73c20..518354623a18 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestPart.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestPart.java @@ -50,7 +50,7 @@ * taking into consideration the 'Content-Type' header of the request part. * {@link RequestParam} is likely to be used with name-value form fields while * {@link RequestPart} is likely to be used with parts containing more complex content - * e.g. JSON, XML). + * for example, JSON, XML). * * @author Rossen Stoyanchev * @author Arjen Poutsma diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/SessionAttribute.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/SessionAttribute.java index 7d6775308ee8..f2132a7b7cce 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/SessionAttribute.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/SessionAttribute.java @@ -28,7 +28,7 @@ * Annotation to bind a method parameter to a session attribute. * *

          The main motivation is to provide convenient access to existing, permanent - * session attributes (e.g. user authentication object) with an optional/required + * session attributes (for example, user authentication object) with an optional/required * check and a cast to the target method parameter type. * *

          For use cases that require adding or removing session attributes consider diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/SessionAttributes.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/SessionAttributes.java index 218af83d3c2c..6a422e37c295 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/SessionAttributes.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/SessionAttributes.java @@ -41,12 +41,12 @@ * to be stored in the session temporarily during the course of a * specific handler's conversation. * - *

          For permanent session attributes, e.g. a user authentication object, + *

          For permanent session attributes, for example, a user authentication object, * use the traditional {@code session.setAttribute} method instead. * Alternatively, consider using the attribute management capabilities of the * generic {@link org.springframework.web.context.request.WebRequest} interface. * - *

          NOTE: When using controller interfaces (e.g. for AOP proxying), + *

          NOTE: When using controller interfaces (for example, for AOP proxying), * make sure to consistently put all your mapping annotations — * such as {@code @RequestMapping} and {@code @SessionAttributes} — on * the controller interface rather than on the implementation class. diff --git a/spring-web/src/main/java/org/springframework/web/bind/support/DefaultDataBinderFactory.java b/spring-web/src/main/java/org/springframework/web/bind/support/DefaultDataBinderFactory.java index 51fe506d13ad..35bbfd51de2b 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/support/DefaultDataBinderFactory.java +++ b/spring-web/src/main/java/org/springframework/web/bind/support/DefaultDataBinderFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 final WebDataBinder createBinder( } /** - * {@inheritDoc}. + * {@inheritDoc} *

          By default, if the parameter has {@code @Valid}, Bean Validation is * excluded, deferring to method validation. */ @@ -129,7 +129,7 @@ protected WebDataBinder createBinderInstance( /** * Extension point to further initialize the created data binder instance - * (e.g. with {@code @InitBinder} methods) after "global" initialization + * (for example, with {@code @InitBinder} methods) after "global" initialization * via {@link WebBindingInitializer}. * @param dataBinder the data binder instance to customize * @param webRequest the current request diff --git a/spring-web/src/main/java/org/springframework/web/bind/support/SessionStatus.java b/spring-web/src/main/java/org/springframework/web/bind/support/SessionStatus.java index 395be0e5535e..3497e5d3c5fb 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/support/SessionStatus.java +++ b/spring-web/src/main/java/org/springframework/web/bind/support/SessionStatus.java @@ -19,7 +19,7 @@ /** * Simple interface that can be injected into handler methods, allowing them to * signal that their session processing is complete. The handler invoker may - * then follow up with appropriate cleanup, e.g. of session attributes which + * then follow up with appropriate cleanup, for example, of session attributes which * have been implicitly created during this handler's processing (according to * the * {@link org.springframework.web.bind.annotation.SessionAttributes @SessionAttributes} diff --git a/spring-web/src/main/java/org/springframework/web/bind/support/SpringWebConstraintValidatorFactory.java b/spring-web/src/main/java/org/springframework/web/bind/support/SpringWebConstraintValidatorFactory.java index dcbc1a4c7a86..149f92f29161 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/support/SpringWebConstraintValidatorFactory.java +++ b/spring-web/src/main/java/org/springframework/web/bind/support/SpringWebConstraintValidatorFactory.java @@ -30,7 +30,7 @@ *

          In contrast to * {@link org.springframework.validation.beanvalidation.SpringConstraintValidatorFactory}, * this variant is meant for declarative use in a standard {@code validation.xml} file, - * e.g. in combination with JAX-RS or JAX-WS. + * for example, in combination with JAX-RS or JAX-WS. * * @author Juergen Hoeller * @since 4.2.1 diff --git a/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeBindException.java b/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeBindException.java index d6a90a079549..d7204f7498f3 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeBindException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeBindException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -78,19 +78,6 @@ public Object[] getDetailMessageArguments(MessageSource source, Locale locale) { BindErrorUtils.resolveAndJoin(getFieldErrors(), source, locale)}; } - /** - * Resolve global and field errors to messages with the given - * {@link MessageSource} and {@link Locale}. - * @return a Map with errors as key and resolves messages as value - * @since 6.0.3 - * @deprecated in favor of using {@link BindErrorUtils} and - * {@link #getAllErrors()}, to be removed in 6.2 - */ - @Deprecated(since = "6.1", forRemoval = true) - public Map resolveErrorMessages(MessageSource messageSource, Locale locale) { - return BindErrorUtils.resolve(getAllErrors(), messageSource, locale); - } - // BindingResult implementation methods diff --git a/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeDataBinder.java b/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeDataBinder.java index 10cc5a8b2639..4bfeb0632646 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeDataBinder.java +++ b/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeDataBinder.java @@ -148,13 +148,19 @@ public static Mono> extractValuesToBind(ServerWebExchange ex protected static void addBindValue(Map params, String key, List values) { if (!CollectionUtils.isEmpty(values)) { - values = values.stream() - .map(value -> value instanceof FormFieldPart formFieldPart ? formFieldPart.value() : value) - .toList(); - params.put(key, values.size() == 1 ? values.get(0) : values); + if (values.size() == 1) { + params.put(key, adaptBindValue(values.get(0))); + } + else { + params.put(key, values.stream().map(WebExchangeDataBinder::adaptBindValue).toList()); + } } } + private static Object adaptBindValue(Object value) { + return (value instanceof FormFieldPart part ? part.value() : value); + } + /** * Resolve values from a map. diff --git a/spring-web/src/main/java/org/springframework/web/bind/support/WebRequestDataBinder.java b/spring-web/src/main/java/org/springframework/web/bind/support/WebRequestDataBinder.java index 1982222cadb1..2d7d31264f61 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/support/WebRequestDataBinder.java +++ b/spring-web/src/main/java/org/springframework/web/bind/support/WebRequestDataBinder.java @@ -55,7 +55,7 @@ * *

          Can also used for manual data binding in custom web controllers or interceptors * that build on Spring's {@link org.springframework.web.context.request.WebRequest} - * abstraction: e.g. in a {@link org.springframework.web.context.request.WebRequestInterceptor} + * abstraction: for example, in a {@link org.springframework.web.context.request.WebRequestInterceptor} * implementation. Simply instantiate a WebRequestDataBinder for each binding * process, and invoke {@code bind} with the current WebRequest as argument: * diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java b/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java index 86589daaac01..1388224300b4 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.net.URI; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Collections; @@ -28,6 +29,7 @@ import org.springframework.core.ResolvableType; import org.springframework.core.log.LogFormatUtils; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; @@ -36,7 +38,6 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; -import org.springframework.util.FileCopyUtils; import org.springframework.util.ObjectUtils; /** @@ -48,8 +49,8 @@ * {@link #hasError(HttpStatusCode)}. Unknown status codes will be ignored by * {@link #hasError(ClientHttpResponse)}. * - *

          See {@link #handleError(ClientHttpResponse)} for more details on specific - * exception types. + *

          See {@link #handleError(URI, HttpMethod, ClientHttpResponse)} for more + * details on specific exception types. * * @author Arjen Poutsma * @author Rossen Stoyanchev @@ -116,7 +117,8 @@ protected boolean hasError(int statusCode) { } /** - * Handle the error in the given response with the given resolved status code. + * Handle the error in the given response with the given resolved status code + * and extra information providing access to the request URL and HTTP method. *

          The default implementation throws: *

            *
          • {@link HttpClientErrorException} if the status code is in the 4xx @@ -129,54 +131,55 @@ protected boolean hasError(int statusCode) { * {@link HttpStatus} enum range. *
          * @throws UnknownHttpStatusCodeException in case of an unresolvable status code - * @see #handleError(ClientHttpResponse, HttpStatusCode) + * @since 6.2 + * @see #handleError(ClientHttpResponse, HttpStatusCode, URI, HttpMethod) */ @Override - public void handleError(ClientHttpResponse response) throws IOException { - HttpStatusCode statusCode = response.getStatusCode(); - handleError(response, statusCode); - } - - /** - * Return error message with details from the response body. For example: - *
          -	 * 404 Not Found: [{'id': 123, 'message': 'my message'}]
          -	 * 
          - */ - private String getErrorMessage( - int rawStatusCode, String statusText, @Nullable byte[] responseBody, @Nullable Charset charset) { - - String preface = rawStatusCode + " " + statusText + ": "; + public void handleError(URI url, HttpMethod method, ClientHttpResponse response) throws IOException { - if (ObjectUtils.isEmpty(responseBody)) { - return preface + "[no body]"; + // For backwards compatibility try handle(response) first + HandleErrorResponseDecorator decorator = new HandleErrorResponseDecorator(response); + handleError(decorator); + if (decorator.isHandled()) { + return; } - charset = (charset != null ? charset : StandardCharsets.UTF_8); + handleError(response, response.getStatusCode(), url, method); + } + + @SuppressWarnings("removal") + @Override + public void handleError(ClientHttpResponse response) throws IOException { - String bodyText = new String(responseBody, charset); - bodyText = LogFormatUtils.formatValue(bodyText, -1, true); + // Called via handleError(url, method, response) + if (response instanceof HandleErrorResponseDecorator decorator) { + decorator.setNotHandled(); + return; + } - return preface + bodyText; + // Called directly, so do handle + handleError(response, response.getStatusCode(), null, null); } /** * Handle the error based on the resolved status code. - * *

          The default implementation delegates to * {@link HttpClientErrorException#create} for errors in the 4xx range, to * {@link HttpServerErrorException#create} for errors in the 5xx range, * or otherwise raises {@link UnknownHttpStatusCodeException}. - * @since 5.0 + * @since 6.2 * @see HttpClientErrorException#create * @see HttpServerErrorException#create */ - protected void handleError(ClientHttpResponse response, HttpStatusCode statusCode) throws IOException { + protected void handleError( + ClientHttpResponse response, HttpStatusCode statusCode, + @Nullable URI url, @Nullable HttpMethod method) throws IOException { + String statusText = response.getStatusText(); HttpHeaders headers = response.getHeaders(); byte[] body = getResponseBody(response); Charset charset = getCharset(response); - String message = getErrorMessage(statusCode.value(), statusText, body, charset); + String message = getErrorMessage(statusCode.value(), statusText, body, charset, url, method); RestClientResponseException ex; if (statusCode.is4xxClientError()) { @@ -196,11 +199,74 @@ else if (statusCode.is5xxServerError()) { throw ex; } + /** + * Read the body of the given response (for inclusion in a status exception). + * @param response the response to inspect + * @return the response body as a byte array, + * or an empty byte array if the body could not be read + * @since 4.3.8 + */ + protected byte[] getResponseBody(ClientHttpResponse response) { + return RestClientUtils.getBody(response); + } + + /** + * Determine the charset of the response (for inclusion in a status exception). + * @param response the response to inspect + * @return the associated charset, or {@code null} if none + * @since 4.3.8 + */ + @Nullable + protected Charset getCharset(ClientHttpResponse response) { + MediaType contentType = response.getHeaders().getContentType(); + return (contentType != null ? contentType.getCharset() : null); + } + + /** + * Return an error message with details from the response body. For example: + *

          +	 * 404 Not Found on GET request for "https://example.com": [{'id': 123, 'message': 'my message'}]
          +	 * 
          + */ + private String getErrorMessage( + int rawStatusCode, String statusText, @Nullable byte[] responseBody, @Nullable Charset charset, + @Nullable URI url, @Nullable HttpMethod method) { + + StringBuilder msg = new StringBuilder(rawStatusCode + " " + statusText); + if (method != null) { + msg.append(" on ").append(method).append(" request"); + } + if (url != null) { + msg.append(" for \""); + String urlString = url.toString(); + int idx = urlString.indexOf('?'); + if (idx != -1) { + msg.append(urlString, 0, idx); + } + else { + msg.append(urlString); + } + msg.append("\""); + } + msg.append(": "); + if (ObjectUtils.isEmpty(responseBody)) { + msg.append("[no body]"); + } + else { + charset = (charset != null ? charset : StandardCharsets.UTF_8); + String bodyText = new String(responseBody, charset); + bodyText = LogFormatUtils.formatValue(bodyText, -1, true); + msg.append(bodyText); + } + return msg.toString(); + } + /** * Return a function for decoding the error content. This can be passed to * {@link RestClientResponseException#setBodyConvertFunction(Function)}. * @since 6.0 */ + @SuppressWarnings("NullAway") protected Function initBodyConvertFunction(ClientHttpResponse response, byte[] body) { Assert.state(!CollectionUtils.isEmpty(this.messageConverters), "Expected message converters"); return resolvableType -> { @@ -222,34 +288,22 @@ public InputStream getBody() { }; } - /** - * Read the body of the given response (for inclusion in a status exception). - * @param response the response to inspect - * @return the response body as a byte array, - * or an empty byte array if the body could not be read - * @since 4.3.8 - */ - protected byte[] getResponseBody(ClientHttpResponse response) { - try { - return FileCopyUtils.copyToByteArray(response.getBody()); + + private static class HandleErrorResponseDecorator extends ClientHttpResponseDecorator { + + private boolean handled = true; + + public HandleErrorResponseDecorator(ClientHttpResponse delegate) { + super(delegate); } - catch (IOException ex) { - // ignore + + public void setNotHandled() { + this.handled = false; } - return new byte[0]; - } - /** - * Determine the charset of the response (for inclusion in a status exception). - * @param response the response to inspect - * @return the associated charset, or {@code null} if none - * @since 4.3.8 - */ - @Nullable - protected Charset getCharset(ClientHttpResponse response) { - HttpHeaders headers = response.getHeaders(); - MediaType contentType = headers.getContentType(); - return (contentType != null ? contentType.getCharset() : null); + public boolean isHandled() { + return this.handled; + } } } diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java index dcfb734e7da9..9e88dfdc91a0 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java @@ -28,6 +28,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; @@ -59,9 +60,12 @@ import org.springframework.http.converter.GenericHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.SmartHttpMessageConverter; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.util.UriBuilder; import org.springframework.web.util.UriBuilderFactory; @@ -82,6 +86,8 @@ final class DefaultRestClient implements RestClient { private static final ClientRequestObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultClientRequestObservationConvention(); + private static final String URI_TEMPLATE_ATTRIBUTE = RestClient.class.getName() + ".uriTemplate"; + private final ClientHttpRequestFactory clientRequestFactory; @@ -99,6 +105,9 @@ final class DefaultRestClient implements RestClient { @Nullable private final HttpHeaders defaultHeaders; + @Nullable + private final MultiValueMap defaultCookies; + @Nullable private final Consumer> defaultRequest; @@ -119,6 +128,7 @@ final class DefaultRestClient implements RestClient { @Nullable List initializers, UriBuilderFactory uriBuilderFactory, @Nullable HttpHeaders defaultHeaders, + @Nullable MultiValueMap defaultCookies, @Nullable Consumer> defaultRequest, @Nullable List statusHandlers, List> messageConverters, @@ -131,6 +141,7 @@ final class DefaultRestClient implements RestClient { this.interceptors = interceptors; this.uriBuilderFactory = uriBuilderFactory; this.defaultHeaders = defaultHeaders; + this.defaultCookies = defaultCookies; this.defaultRequest = defaultRequest; this.defaultStatusHandlers = (statusHandlers != null ? new ArrayList<>(statusHandlers) : new ArrayList<>()); this.messageConverters = messageConverters; @@ -181,7 +192,11 @@ public RequestBodyUriSpec method(HttpMethod method) { } private RequestBodyUriSpec methodInternal(HttpMethod httpMethod) { - return new DefaultRequestBodyUriSpec(httpMethod); + DefaultRequestBodyUriSpec spec = new DefaultRequestBodyUriSpec(httpMethod); + if (this.defaultRequest != null) { + this.defaultRequest.accept(spec); + } + return spec; } @Override @@ -191,8 +206,8 @@ public Builder mutate() { @Nullable @SuppressWarnings({"rawtypes", "unchecked"}) - private T readWithMessageConverters(ClientHttpResponse clientResponse, Runnable callback, Type bodyType, - Class bodyClass, @Nullable Observation observation) { + private T readWithMessageConverters( + ClientHttpResponse clientResponse, Runnable callback, Type bodyType, Class bodyClass) { MediaType contentType = getContentType(clientResponse); @@ -205,15 +220,24 @@ private T readWithMessageConverters(ClientHttpResponse clientResponse, Runna } for (HttpMessageConverter messageConverter : this.messageConverters) { - if (messageConverter instanceof GenericHttpMessageConverter genericHttpMessageConverter) { - if (genericHttpMessageConverter.canRead(bodyType, null, contentType)) { + if (messageConverter instanceof GenericHttpMessageConverter genericMessageConverter) { + if (genericMessageConverter.canRead(bodyType, null, contentType)) { if (logger.isDebugEnabled()) { logger.debug("Reading to [" + ResolvableType.forType(bodyType) + "]"); } - return (T) genericHttpMessageConverter.read(bodyType, null, responseWrapper); + return (T) genericMessageConverter.read(bodyType, null, responseWrapper); } } - if (messageConverter.canRead(bodyClass, contentType)) { + else if (messageConverter instanceof SmartHttpMessageConverter smartMessageConverter) { + ResolvableType resolvableType = ResolvableType.forType(bodyType); + if (smartMessageConverter.canRead(resolvableType, contentType)) { + if (logger.isDebugEnabled()) { + logger.debug("Reading to [" + resolvableType + "]"); + } + return (T) smartMessageConverter.read(resolvableType, responseWrapper, null); + } + } + else if (messageConverter.canRead(bodyClass, contentType)) { if (logger.isDebugEnabled()) { logger.debug("Reading to [" + bodyClass.getName() + "] as \"" + contentType + "\""); } @@ -233,21 +257,8 @@ private T readWithMessageConverters(ClientHttpResponse clientResponse, Runna else { cause = exc; } - RestClientException restClientException = new RestClientException("Error while extracting response for type [" + + throw new RestClientException("Error while extracting response for type [" + ResolvableType.forType(bodyType) + "] and content type [" + contentType + "]", cause); - if (observation != null) { - observation.error(restClientException); - } - throw restClientException; - } - catch (RestClientException restClientException) { - if (observation != null) { - observation.error(restClientException); - } - throw restClientException; - } - finally { - clientResponse.close(); } } @@ -284,11 +295,14 @@ private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec { @Nullable private HttpHeaders headers; + @Nullable + private MultiValueMap cookies; + @Nullable private InternalBody body; @Nullable - private String uriTemplate; + private Map attributes; @Nullable private Consumer httpRequestConsumer; @@ -299,19 +313,22 @@ public DefaultRequestBodyUriSpec(HttpMethod httpMethod) { @Override public RequestBodySpec uri(String uriTemplate, Object... uriVariables) { - this.uriTemplate = uriTemplate; + UriBuilder uriBuilder = uriBuilderFactory.uriString(uriTemplate); + attribute(URI_TEMPLATE_ATTRIBUTE, uriBuilder.toUriString()); return uri(DefaultRestClient.this.uriBuilderFactory.expand(uriTemplate, uriVariables)); } @Override public RequestBodySpec uri(String uriTemplate, Map uriVariables) { - this.uriTemplate = uriTemplate; + UriBuilder uriBuilder = uriBuilderFactory.uriString(uriTemplate); + attribute(URI_TEMPLATE_ATTRIBUTE, uriBuilder.toUriString()); return uri(DefaultRestClient.this.uriBuilderFactory.expand(uriTemplate, uriVariables)); } @Override public RequestBodySpec uri(String uriTemplate, Function uriFunction) { - this.uriTemplate = uriTemplate; + UriBuilder uriBuilder = uriBuilderFactory.uriString(uriTemplate); + attribute(URI_TEMPLATE_ATTRIBUTE, uriBuilder.toUriString()); return uri(uriFunction.apply(DefaultRestClient.this.uriBuilderFactory.uriString(uriTemplate))); } @@ -322,7 +339,13 @@ public RequestBodySpec uri(Function uriFunction) { @Override public RequestBodySpec uri(URI uri) { - this.uri = uri; + if (uri.isAbsolute()) { + this.uri = uri; + } + else { + URI baseUri = DefaultRestClient.this.uriBuilderFactory.expand(""); + this.uri = baseUri.resolve(uri); + } return this; } @@ -333,6 +356,13 @@ private HttpHeaders getHeaders() { return this.headers; } + private MultiValueMap getCookies() { + if (this.cookies == null) { + this.cookies = new LinkedMultiValueMap<>(3); + } + return this.cookies; + } + @Override public DefaultRequestBodyUriSpec header(String headerName, String... headerValues) { for (String headerValue : headerValues) { @@ -359,6 +389,18 @@ public DefaultRequestBodyUriSpec acceptCharset(Charset... acceptableCharsets) { return this; } + @Override + public DefaultRequestBodyUriSpec cookie(String name, String value) { + getCookies().add(name, value); + return this; + } + + @Override + public DefaultRequestBodyUriSpec cookies(Consumer> cookiesConsumer) { + cookiesConsumer.accept(getCookies()); + return this; + } + @Override public DefaultRequestBodyUriSpec contentType(MediaType contentType) { getHeaders().setContentType(contentType); @@ -383,6 +425,27 @@ public DefaultRequestBodyUriSpec ifNoneMatch(String... ifNoneMatches) { return this; } + @Override + public RequestBodySpec attribute(String name, Object value) { + getAttributes().put(name, value); + return this; + } + + @Override + public RequestBodySpec attributes(Consumer> attributesConsumer) { + attributesConsumer.accept(getAttributes()); + return this; + } + + private Map getAttributes() { + Map attributes = this.attributes; + if (attributes == null) { + attributes = new ConcurrentHashMap<>(4); + this.attributes = attributes; + } + return attributes; + } + @Override public RequestBodySpec httpRequest(Consumer requestConsumer) { this.httpRequestConsumer = (this.httpRequestConsumer != null ? @@ -423,7 +486,15 @@ private void writeWithMessageConverters(Object body, Type bodyType, ClientHttpRe return; } } - if (messageConverter.canWrite(bodyClass, contentType)) { + else if (messageConverter instanceof SmartHttpMessageConverter smartMessageConverter) { + ResolvableType resolvableType = ResolvableType.forType(bodyType); + if (smartMessageConverter.canWrite(resolvableType, bodyClass, contentType)) { + logBody(body, contentType, smartMessageConverter); + smartMessageConverter.write(body, resolvableType, contentType, clientRequest, null); + return; + } + } + else if (messageConverter.canWrite(bodyClass, contentType)) { logBody(body, contentType, messageConverter); messageConverter.write(body, contentType, clientRequest); return; @@ -455,9 +526,7 @@ private void logBody(Object body, @Nullable MediaType mediaType, HttpMessageConv @Override public ResponseSpec retrieve() { - ResponseSpec responseSpec = exchangeInternal(DefaultResponseSpec::new, false); - Assert.state(responseSpec != null, "No ResponseSpec"); - return responseSpec; + return new DefaultResponseSpec(this); } @Override @@ -475,15 +544,21 @@ private T exchangeInternal(ExchangeFunction exchangeFunction, boolean clo Observation.Scope observationScope = null; URI uri = null; try { - if (DefaultRestClient.this.defaultRequest != null) { - DefaultRestClient.this.defaultRequest.accept(this); - } uri = initUri(); + String serializedCookies = serializeCookies(); + if (serializedCookies != null) { + getHeaders().set(HttpHeaders.COOKIE, serializedCookies); + } HttpHeaders headers = initHeaders(); + ClientHttpRequest clientRequest = createRequest(uri); - clientRequest.getHeaders().addAll(headers); + if (headers != null) { + clientRequest.getHeaders().addAll(headers); + } + Map attributes = getAttributes(); + clientRequest.getAttributes().putAll(attributes); ClientRequestObservationContext observationContext = new ClientRequestObservationContext(clientRequest); - observationContext.setUriTemplate(this.uriTemplate); + observationContext.setUriTemplate((String) attributes.get(URI_TEMPLATE_ATTRIBUTE)); observation = ClientHttpObservationDocumentation.HTTP_CLIENT_EXCHANGES.observation(observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, observationRegistry).start(); observationScope = observation.openScope(); @@ -495,39 +570,31 @@ private T exchangeInternal(ExchangeFunction exchangeFunction, boolean clo } clientResponse = clientRequest.execute(); observationContext.setResponse(clientResponse); - ConvertibleClientHttpResponse convertibleWrapper = new DefaultConvertibleClientHttpResponse(clientResponse, observation, observationScope); + ConvertibleClientHttpResponse convertibleWrapper = new DefaultConvertibleClientHttpResponse(clientResponse); return exchangeFunction.exchange(clientRequest, convertibleWrapper); } catch (IOException ex) { ResourceAccessException resourceAccessException = createResourceAccessException(uri, this.httpMethod, ex); - if (observationScope != null) { - observationScope.close(); - } if (observation != null) { observation.error(resourceAccessException); - observation.stop(); } throw resourceAccessException; } catch (Throwable error) { - if (observationScope != null) { - observationScope.close(); - } if (observation != null) { observation.error(error); - observation.stop(); } throw error; } finally { + if (observationScope != null) { + observationScope.close(); + } + if (observation != null) { + observation.stop(); + } if (close && clientResponse != null) { clientResponse.close(); - if (observationScope != null) { - observationScope.close(); - } - if (observation != null) { - observation.stop(); - } } } } @@ -536,10 +603,46 @@ private URI initUri() { return (this.uri != null ? this.uri : DefaultRestClient.this.uriBuilderFactory.expand("")); } + @Nullable + private String serializeCookies() { + MultiValueMap map; + MultiValueMap defaultCookies = DefaultRestClient.this.defaultCookies; + if (CollectionUtils.isEmpty(this.cookies)) { + map = defaultCookies; + } + else if (CollectionUtils.isEmpty(defaultCookies)) { + map = this.cookies; + } + else { + map = new LinkedMultiValueMap<>(defaultCookies.size() + this.cookies.size()); + map.putAll(defaultCookies); + map.putAll(this.cookies); + } + return (!CollectionUtils.isEmpty(map) ? serializeCookies(map) : null); + } + + private static String serializeCookies(MultiValueMap map) { + boolean first = true; + StringBuilder sb = new StringBuilder(); + for (Map.Entry> entry : map.entrySet()) { + for (String value : entry.getValue()) { + if (!first) { + sb.append("; "); + } + else { + first = false; + } + sb.append(entry.getKey()).append("=").append(value); + } + } + return sb.toString(); + } + + @Nullable private HttpHeaders initHeaders() { HttpHeaders defaultHeaders = DefaultRestClient.this.defaultHeaders; if (CollectionUtils.isEmpty(this.headers)) { - return (defaultHeaders != null ? defaultHeaders : new HttpHeaders()); + return defaultHeaders; } else if (CollectionUtils.isEmpty(defaultHeaders)) { return this.headers; @@ -557,7 +660,8 @@ private ClientHttpRequest createRequest(URI uri) throws IOException { if (DefaultRestClient.this.interceptors != null) { factory = DefaultRestClient.this.interceptingRequestFactory; if (factory == null) { - factory = new InterceptingClientHttpRequestFactory(DefaultRestClient.this.clientRequestFactory, DefaultRestClient.this.interceptors); + factory = new InterceptingClientHttpRequestFactory( + DefaultRestClient.this.clientRequestFactory, DefaultRestClient.this.interceptors); DefaultRestClient.this.interceptingRequestFactory = factory; } } @@ -599,17 +703,14 @@ private interface InternalBody { private class DefaultResponseSpec implements ResponseSpec { - private final HttpRequest clientRequest; - - private final ClientHttpResponse clientResponse; + private final RequestHeadersSpec requestHeadersSpec; private final List statusHandlers = new ArrayList<>(1); private final int defaultStatusHandlerCount; - DefaultResponseSpec(HttpRequest clientRequest, ClientHttpResponse clientResponse) { - this.clientRequest = clientRequest; - this.clientResponse = clientResponse; + DefaultResponseSpec(RequestHeadersSpec requestHeadersSpec) { + this.requestHeadersSpec = requestHeadersSpec; this.statusHandlers.addAll(DefaultRestClient.this.defaultStatusHandlers); this.statusHandlers.add(StatusHandler.defaultHandler(DefaultRestClient.this.messageConverters)); this.defaultStatusHandlerCount = this.statusHandlers.size(); @@ -641,7 +742,7 @@ private ResponseSpec onStatusInternal(StatusHandler statusHandler) { @Override @Nullable public T body(Class bodyType) { - return readBody(bodyType, bodyType); + return executeAndExtract((request, response) -> readBody(request, response, bodyType, bodyType)); } @Override @@ -649,7 +750,7 @@ public T body(Class bodyType) { public T body(ParameterizedTypeReference bodyType) { Type type = bodyType.getType(); Class bodyClass = bodyClass(type); - return readBody(type, bodyClass); + return executeAndExtract((request, response) -> readBody(request, response, type, bodyClass)); } @Override @@ -665,50 +766,64 @@ public ResponseEntity toEntity(ParameterizedTypeReference bodyType) { } private ResponseEntity toEntityInternal(Type bodyType, Class bodyClass) { - T body = readBody(bodyType, bodyClass); - try { - return ResponseEntity.status(this.clientResponse.getStatusCode()) - .headers(this.clientResponse.getHeaders()) - .body(body); - } - catch (IOException ex) { - throw new ResourceAccessException("Could not retrieve response status code: " + ex.getMessage(), ex); - } + ResponseEntity entity = executeAndExtract((request, response) -> { + T body = readBody(request, response, bodyType, bodyClass); + try { + return ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(body); + } + catch (IOException ex) { + throw new ResourceAccessException( + "Could not retrieve response status code: " + ex.getMessage(), ex); + } + }); + Assert.state(entity != null, "No ResponseEntity"); + return entity; } @Override public ResponseEntity toBodilessEntity() { - try (this.clientResponse) { - applyStatusHandlers(); - return ResponseEntity.status(this.clientResponse.getStatusCode()) - .headers(this.clientResponse.getHeaders()) - .build(); - } - catch (UncheckedIOException ex) { - throw new ResourceAccessException("Could not retrieve response status code: " + ex.getMessage(), ex.getCause()); - } - catch (IOException ex) { - throw new ResourceAccessException("Could not retrieve response status code: " + ex.getMessage(), ex); - } + ResponseEntity entity = executeAndExtract((request, response) -> { + try (response) { + applyStatusHandlers(request, response); + return ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()) + .build(); + } + catch (UncheckedIOException ex) { + throw new ResourceAccessException( + "Could not retrieve response status code: " + ex.getMessage(), ex.getCause()); + } + catch (IOException ex) { + throw new ResourceAccessException( + "Could not retrieve response status code: " + ex.getMessage(), ex); + } + }); + Assert.state(entity != null, "No ResponseEntity"); + return entity; } + @Nullable + public T executeAndExtract(RequestHeadersSpec.ExchangeFunction exchangeFunction) { + return this.requestHeadersSpec.exchange(exchangeFunction); + } @Nullable - private T readBody(Type bodyType, Class bodyClass) { - return DefaultRestClient.this.readWithMessageConverters(this.clientResponse, this::applyStatusHandlers, - bodyType, bodyClass, getCurrentObservation()); + private T readBody(HttpRequest request, ClientHttpResponse response, Type bodyType, Class bodyClass) { + return DefaultRestClient.this.readWithMessageConverters( + response, () -> applyStatusHandlers(request, response), bodyType, bodyClass); } - private void applyStatusHandlers() { + private void applyStatusHandlers(HttpRequest request, ClientHttpResponse response) { try { - ClientHttpResponse response = this.clientResponse; if (response instanceof DefaultConvertibleClientHttpResponse convertibleResponse) { response = convertibleResponse.delegate; } for (StatusHandler handler : this.statusHandlers) { if (handler.test(response)) { - handler.handle(this.clientRequest, response); + handler.handle(request, response); return; } } @@ -718,14 +833,6 @@ private void applyStatusHandlers() { } } - @Nullable - private Observation getCurrentObservation() { - if (this.clientResponse instanceof DefaultConvertibleClientHttpResponse convertibleResponse) { - return convertibleResponse.observation; - } - return null; - } - } @@ -733,21 +840,14 @@ private class DefaultConvertibleClientHttpResponse implements RequestHeadersSpec private final ClientHttpResponse delegate; - private final Observation observation; - - private final Observation.Scope observationScope; - - public DefaultConvertibleClientHttpResponse(ClientHttpResponse delegate, Observation observation, Observation.Scope observationScope) { + public DefaultConvertibleClientHttpResponse(ClientHttpResponse delegate) { this.delegate = delegate; - this.observation = observation; - this.observationScope = observationScope; } - @Nullable @Override public T bodyTo(Class bodyType) { - return readWithMessageConverters(this.delegate, () -> {} , bodyType, bodyType, this.observation); + return readWithMessageConverters(this.delegate, () -> {} , bodyType, bodyType); } @Nullable @@ -755,7 +855,7 @@ public T bodyTo(Class bodyType) { public T bodyTo(ParameterizedTypeReference bodyType) { Type type = bodyType.getType(); Class bodyClass = bodyClass(type); - return readWithMessageConverters(this.delegate, () -> {}, type, bodyClass, this.observation); + return readWithMessageConverters(this.delegate, () -> {}, type, bodyClass); } @Override @@ -781,8 +881,6 @@ public String getStatusText() throws IOException { @Override public void close() { this.delegate.close(); - this.observationScope.close(); - this.observation.stop(); } } diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java index 82e2a9f219ec..975b1f183f9d 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java @@ -16,8 +16,10 @@ package org.springframework.web.client; +import java.net.URI; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -35,6 +37,7 @@ import org.springframework.http.client.InterceptingClientHttpRequestFactory; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.http.client.JettyClientHttpRequestFactory; +import org.springframework.http.client.ReactorClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.http.client.observation.ClientRequestObservationConvention; import org.springframework.http.converter.ByteArrayHttpMessageConverter; @@ -48,10 +51,13 @@ import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.smile.MappingJackson2SmileHttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; +import org.springframework.http.converter.yaml.MappingJackson2YamlHttpMessageConverter; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.util.DefaultUriBuilderFactory; import org.springframework.web.util.UriBuilderFactory; import org.springframework.web.util.UriTemplateHandler; @@ -60,6 +66,8 @@ * Default implementation of {@link RestClient.Builder}. * * @author Arjen Poutsma + * @author Hyoungjune Kim + * @author Sebastien Deleuze * @since 6.1 */ final class DefaultRestClientBuilder implements RestClient.Builder { @@ -70,6 +78,8 @@ final class DefaultRestClientBuilder implements RestClient.Builder { private static final boolean jettyClientPresent; + private static final boolean reactorNettyClientPresent; + private static final boolean jdkClientPresent; // message factories @@ -86,12 +96,15 @@ final class DefaultRestClientBuilder implements RestClient.Builder { private static final boolean jackson2CborPresent; + private static final boolean jackson2YamlPresent; + static { ClassLoader loader = DefaultRestClientBuilder.class.getClassLoader(); httpComponentsClientPresent = ClassUtils.isPresent("org.apache.hc.client5.http.classic.HttpClient", loader); jettyClientPresent = ClassUtils.isPresent("org.eclipse.jetty.client.HttpClient", loader); + reactorNettyClientPresent = ClassUtils.isPresent("reactor.netty.http.client.HttpClient", loader); jdkClientPresent = ClassUtils.isPresent("java.net.http.HttpClient", loader); jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", loader) && @@ -101,6 +114,7 @@ final class DefaultRestClientBuilder implements RestClient.Builder { kotlinSerializationJsonPresent = ClassUtils.isPresent("kotlinx.serialization.json.Json", loader); jackson2SmilePresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", loader); jackson2CborPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", loader); + jackson2YamlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.yaml.YAMLFactory", loader); } @Nullable @@ -115,6 +129,9 @@ final class DefaultRestClientBuilder implements RestClient.Builder { @Nullable private HttpHeaders defaultHeaders; + @Nullable + private MultiValueMap defaultCookies; + @Nullable private Consumer> defaultRequest; @@ -157,6 +174,8 @@ public DefaultRestClientBuilder(DefaultRestClientBuilder other) { else { this.defaultHeaders = null; } + this.defaultCookies = (other.defaultCookies != null ? + new LinkedMultiValueMap<>(other.defaultCookies) : null); this.defaultRequest = other.defaultRequest; this.statusHandlers = (other.statusHandlers != null ? new ArrayList<>(other.statusHandlers) : null); @@ -240,6 +259,12 @@ public RestClient.Builder baseUrl(String baseUrl) { return this; } + @Override + public RestClient.Builder baseUrl(URI baseUrl) { + this.baseUrl = baseUrl.toString(); + return this; + } + @Override public RestClient.Builder defaultUriVariables(Map defaultUriVariables) { this.defaultUriVariables = defaultUriVariables; @@ -271,6 +296,25 @@ private HttpHeaders initHeaders() { return this.defaultHeaders; } + @Override + public RestClient.Builder defaultCookie(String cookie, String... values) { + initCookies().addAll(cookie, Arrays.asList(values)); + return this; + } + + @Override + public RestClient.Builder defaultCookies(Consumer> cookiesConsumer) { + cookiesConsumer.accept(initCookies()); + return this; + } + + private MultiValueMap initCookies() { + if (this.defaultCookies == null) { + this.defaultCookies = new LinkedMultiValueMap<>(3); + } + return this.defaultCookies; + } + @Override public RestClient.Builder defaultRequest(Consumer> defaultRequest) { this.defaultRequest = this.defaultRequest != null ? @@ -346,6 +390,14 @@ public RestClient.Builder requestFactory(ClientHttpRequestFactory requestFactory @Override public RestClient.Builder messageConverters(Consumer>> configurer) { configurer.accept(initMessageConverters()); + validateConverters(this.messageConverters); + return this; + } + + @Override + public RestClient.Builder messageConverters(List> messageConverters) { + validateConverters(messageConverters); + this.messageConverters = Collections.unmodifiableList(messageConverters); return this; } @@ -394,10 +446,18 @@ else if (jsonbPresent) { if (jackson2CborPresent) { this.messageConverters.add(new MappingJackson2CborHttpMessageConverter()); } + if (jackson2YamlPresent) { + this.messageConverters.add(new MappingJackson2YamlHttpMessageConverter()); + } } return this.messageConverters; } + private void validateConverters(@Nullable List> messageConverters) { + Assert.notEmpty(messageConverters, "At least one HttpMessageConverter is required"); + Assert.noNullElements(messageConverters, "The HttpMessageConverter list must not contain null elements"); + } + @Override public RestClient.Builder clone() { @@ -408,19 +468,21 @@ public RestClient.Builder clone() { public RestClient build() { ClientHttpRequestFactory requestFactory = initRequestFactory(); UriBuilderFactory uriBuilderFactory = initUriBuilderFactory(); + HttpHeaders defaultHeaders = copyDefaultHeaders(); - List> messageConverters = (this.messageConverters != null ? - this.messageConverters : initMessageConverters()); - return new DefaultRestClient(requestFactory, - this.interceptors, this.initializers, uriBuilderFactory, - defaultHeaders, + MultiValueMap defaultCookies = copyDefaultCookies(); + + List> converters = + (this.messageConverters != null ? this.messageConverters : initMessageConverters()); + + return new DefaultRestClient( + requestFactory, this.interceptors, this.initializers, + uriBuilderFactory, defaultHeaders, defaultCookies, this.defaultRequest, this.statusHandlers, - messageConverters, - this.observationRegistry, - this.observationConvention, - new DefaultRestClientBuilder(this) - ); + converters, + this.observationRegistry, this.observationConvention, + new DefaultRestClientBuilder(this)); } private ClientHttpRequestFactory initRequestFactory() { @@ -433,6 +495,9 @@ else if (httpComponentsClientPresent) { else if (jettyClientPresent) { return new JettyClientHttpRequestFactory(); } + else if (reactorNettyClientPresent) { + return new ReactorClientHttpRequestFactory(); + } else if (jdkClientPresent) { // java.net.http module might not be loaded, so we can't default to the JDK HttpClient return new JdkClientHttpRequestFactory(); @@ -454,14 +519,22 @@ private UriBuilderFactory initUriBuilderFactory() { @Nullable private HttpHeaders copyDefaultHeaders() { - if (this.defaultHeaders != null) { - HttpHeaders copy = new HttpHeaders(); - this.defaultHeaders.forEach((key, values) -> copy.put(key, new ArrayList<>(values))); - return HttpHeaders.readOnlyHttpHeaders(copy); + if (this.defaultHeaders == null) { + return null; } - else { + HttpHeaders copy = new HttpHeaders(); + this.defaultHeaders.forEach((key, values) -> copy.put(key, new ArrayList<>(values))); + return HttpHeaders.readOnlyHttpHeaders(copy); + } + + @Nullable + private MultiValueMap copyDefaultCookies() { + if (this.defaultCookies == null) { return null; } + MultiValueMap copy = new LinkedMultiValueMap<>(this.defaultCookies.size()); + this.defaultCookies.forEach((key, values) -> copy.put(key, new ArrayList<>(values))); + return CollectionUtils.unmodifiableMultiValueMap(copy); } } diff --git a/spring-web/src/main/java/org/springframework/web/client/ExtractingResponseErrorHandler.java b/spring-web/src/main/java/org/springframework/web/client/ExtractingResponseErrorHandler.java index 4357be4d2722..e2c95a992b29 100644 --- a/spring-web/src/main/java/org/springframework/web/client/ExtractingResponseErrorHandler.java +++ b/spring-web/src/main/java/org/springframework/web/client/ExtractingResponseErrorHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,11 +17,13 @@ package org.springframework.web.client; import java.io.IOException; +import java.net.URI; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.client.ClientHttpResponse; @@ -30,26 +32,28 @@ import org.springframework.util.CollectionUtils; /** - * Implementation of {@link ResponseErrorHandler} that uses {@link HttpMessageConverter - * HttpMessageConverters} to convert HTTP error responses to {@link RestClientException - * RestClientExceptions}. + * Implementation of {@link ResponseErrorHandler} that uses + * {@link HttpMessageConverter HttpMessageConverters} to convert HTTP error + * responses to {@link RestClientException RestClientExceptions}. * *

          To use this error handler, you must specify a * {@linkplain #setStatusMapping(Map) status mapping} and/or a - * {@linkplain #setSeriesMapping(Map) series mapping}. If either of these mappings has a match - * for the {@linkplain ClientHttpResponse#getStatusCode() status code} of a given - * {@code ClientHttpResponse}, {@link #hasError(ClientHttpResponse)} will return - * {@code true}, and {@link #handleError(ClientHttpResponse)} will attempt to use the - * {@linkplain #setMessageConverters(List) configured message converters} to convert the response - * into the mapped subclass of {@link RestClientException}. Note that the + * {@linkplain #setSeriesMapping(Map) series mapping}. If either of these + * mappings has a match for the {@linkplain ClientHttpResponse#getStatusCode() + * status code} of a given {@code ClientHttpResponse}, + * {@link #hasError(ClientHttpResponse)} will return {@code true}, and + * {@link #handleError(ClientHttpResponse, HttpStatusCode, URI, HttpMethod)} + * will attempt to use the {@linkplain #setMessageConverters(List) configured + * message converters} to convert the response into the mapped subclass of + * {@link RestClientException}. Note that the * {@linkplain #setStatusMapping(Map) status mapping} takes precedence over * {@linkplain #setSeriesMapping(Map) series mapping}. * *

          If there is no match, this error handler will default to the behavior of - * {@link DefaultResponseErrorHandler}. Note that you can override this default behavior - * by specifying a {@linkplain #setSeriesMapping(Map) series mapping} from - * {@code HttpStatus.Series#CLIENT_ERROR} and/or {@code HttpStatus.Series#SERVER_ERROR} - * to {@code null}. + * {@link DefaultResponseErrorHandler}. Note that you can override this default + * behavior by specifying a {@linkplain #setSeriesMapping(Map) series mapping} + * from {@code HttpStatus.Series#CLIENT_ERROR} and/or + * {@code HttpStatus.Series#SERVER_ERROR} to {@code null}. * * @author Simon Galperin * @author Arjen Poutsma @@ -95,9 +99,10 @@ public void setMessageConverters(List> messageConverters * If this mapping has a match * for the {@linkplain ClientHttpResponse#getStatusCode() status code} of a given * {@code ClientHttpResponse}, {@link #hasError(ClientHttpResponse)} will return - * {@code true} and {@link #handleError(ClientHttpResponse)} will attempt to use the - * {@linkplain #setMessageConverters(List) configured message converters} to convert the - * response into the mapped subclass of {@link RestClientException}. + * {@code true} and {@link #handleError(ClientHttpResponse, HttpStatusCode, URI, HttpMethod)} + * will attempt to use the {@linkplain #setMessageConverters(List) configured + * message converters} to convert the response into the mapped subclass of + * {@link RestClientException}. */ public void setStatusMapping(Map> statusMapping) { if (!CollectionUtils.isEmpty(statusMapping)) { @@ -110,9 +115,10 @@ public void setStatusMapping(Map> seriesMapping) { if (!CollectionUtils.isEmpty(seriesMapping)) { @@ -124,11 +130,11 @@ public void setSeriesMapping(Map exceptionClass, - ClientHttpResponse response) throws IOException { + private void extract( + @Nullable Class exceptionClass, ClientHttpResponse response) + throws IOException { if (exceptionClass == null) { return; @@ -158,6 +169,7 @@ private void extract(@Nullable Class exceptionCla HttpMessageConverterExtractor extractor = new HttpMessageConverterExtractor<>(exceptionClass, this.messageConverters); + RestClientException exception = extractor.extractData(response); if (exception != null) { throw exception; diff --git a/spring-web/src/main/java/org/springframework/web/client/HttpMessageConverterExtractor.java b/spring-web/src/main/java/org/springframework/web/client/HttpMessageConverterExtractor.java index 0efb72375d4e..3f79cfa7ece2 100644 --- a/spring-web/src/main/java/org/springframework/web/client/HttpMessageConverterExtractor.java +++ b/spring-web/src/main/java/org/springframework/web/client/HttpMessageConverterExtractor.java @@ -29,6 +29,7 @@ import org.springframework.http.converter.GenericHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.SmartHttpMessageConverter; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.FileCopyUtils; @@ -104,14 +105,21 @@ public T extractData(ClientHttpResponse response) throws IOException { return (T) genericMessageConverter.read(this.responseType, null, responseWrapper); } } - if (this.responseClass != null) { - if (messageConverter.canRead(this.responseClass, contentType)) { + else if (messageConverter instanceof SmartHttpMessageConverter smartMessageConverter) { + ResolvableType resolvableType = ResolvableType.forType(this.responseType); + if (smartMessageConverter.canRead(resolvableType, contentType)) { if (logger.isDebugEnabled()) { - String className = this.responseClass.getName(); - logger.debug("Reading to [" + className + "] as \"" + contentType + "\""); + logger.debug("Reading to [" + resolvableType + "]"); } - return (T) messageConverter.read((Class) this.responseClass, responseWrapper); + return (T) smartMessageConverter.read(resolvableType, responseWrapper, null); + } + } + else if (this.responseClass != null && messageConverter.canRead(this.responseClass, contentType)) { + if (logger.isDebugEnabled()) { + String className = this.responseClass.getName(); + logger.debug("Reading to [" + className + "] as \"" + contentType + "\""); } + return (T) messageConverter.read((Class) this.responseClass, responseWrapper); } } } diff --git a/spring-web/src/main/java/org/springframework/web/client/NoOpResponseErrorHandler.java b/spring-web/src/main/java/org/springframework/web/client/NoOpResponseErrorHandler.java index af0f22a52b12..ae699f2bc5c7 100644 --- a/spring-web/src/main/java/org/springframework/web/client/NoOpResponseErrorHandler.java +++ b/spring-web/src/main/java/org/springframework/web/client/NoOpResponseErrorHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,7 +25,7 @@ /** * A basic, no operation {@link ResponseErrorHandler} implementation suitable - * for ignoring any error using the {@link RestTemplate}. + * for ignoring any error using the {@link RestTemplate} or {@link RestClient}. *

          This implementation is not suitable with the {@link RestClient} as it uses * a list of candidates where the first matching is invoked. If you want to * disable default status handlers with the {@code RestClient}, consider @@ -43,9 +43,4 @@ public boolean hasError(ClientHttpResponse response) throws IOException { return false; } - @Override - public void handleError(ClientHttpResponse response) throws IOException { - // never actually called - } - } diff --git a/spring-web/src/main/java/org/springframework/web/client/ResponseErrorHandler.java b/spring-web/src/main/java/org/springframework/web/client/ResponseErrorHandler.java index db2329f8fef0..5865a166b6b7 100644 --- a/spring-web/src/main/java/org/springframework/web/client/ResponseErrorHandler.java +++ b/spring-web/src/main/java/org/springframework/web/client/ResponseErrorHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2025 the original author 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,8 +23,11 @@ import org.springframework.http.client.ClientHttpResponse; /** - * Strategy interface used by the {@link RestTemplate} to determine - * whether a particular response has an error or not. + * Strategy interface used by the {@link RestTemplate} and {@link RestClient} to + * determine whether a particular response has an error or not. + * + *

          Note that {@code RestClient} also supports and recommends use of + * {@link RestClient.ResponseSpec#onStatus status handlers}. * * @author Arjen Poutsma * @since 3.0 @@ -45,14 +48,6 @@ public interface ResponseErrorHandler { * Handle the error in the given response. *

          This method is only called when {@link #hasError(ClientHttpResponse)} * has returned {@code true}. - * @param response the response with the error - * @throws IOException in case of I/O errors - */ - void handleError(ClientHttpResponse response) throws IOException; - - /** - * Alternative to {@link #handleError(ClientHttpResponse)} with extra - * information providing access to the request URL and HTTP method. * @param url the request URL * @param method the HTTP method * @param response the response with the error @@ -63,4 +58,16 @@ default void handleError(URI url, HttpMethod method, ClientHttpResponse response handleError(response); } + /** + * Handle the error in the given response. + *

          This method is only called when {@link #hasError(ClientHttpResponse)} + * has returned {@code true}. + * @param response the response with the error + * @throws IOException in case of I/O errors + * @deprecated in favor of {@link #handleError(URI, HttpMethod, ClientHttpResponse)} + */ + @Deprecated(since = "6.2.2", forRemoval = true) + default void handleError(ClientHttpResponse response) throws IOException { + } + } diff --git a/spring-web/src/main/java/org/springframework/web/client/RestClient.java b/spring-web/src/main/java/org/springframework/web/client/RestClient.java index 0a1e98304a6e..1b7016d77e5a 100644 --- a/spring-web/src/main/java/org/springframework/web/client/RestClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/RestClient.java @@ -44,7 +44,9 @@ import org.springframework.http.client.ClientHttpResponse; import org.springframework.http.client.observation.ClientRequestObservationConvention; import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.lang.CheckReturnValue; import org.springframework.lang.Nullable; +import org.springframework.util.MultiValueMap; import org.springframework.web.util.DefaultUriBuilderFactory; import org.springframework.web.util.UriBuilder; import org.springframework.web.util.UriBuilderFactory; @@ -73,6 +75,7 @@ *

        * * @author Arjen Poutsma + * @author Sebastien Deleuze * @since 6.1 */ public interface RestClient { @@ -155,16 +158,28 @@ static RestClient create(String baseUrl) { } /** - * Create a new {@code RestClient} based on the configuration of the - * given {@code RestTemplate}. The returned builder is configured with the - * template's + * Variant of {@link #create()} that accepts a default base {@code URI}. For more + * details see {@link Builder#baseUrl(URI) Builder.baseUrl(URI)}. + * @param baseUrl the base URI for all requests + * @since 6.2 + * @see #builder() + */ + static RestClient create(URI baseUrl) { + return new DefaultRestClientBuilder().baseUrl(baseUrl).build(); + } + + /** + * Create a new {@code RestClient} based on the configuration of the given + * {@code RestTemplate}. + *

        The returned builder is configured with the following attributes of + * the template. *

          - *
        • {@link RestTemplate#getRequestFactory() ClientHttpRequestFactory},
        • - *
        • {@link RestTemplate#getMessageConverters() HttpMessageConverters},
        • - *
        • {@link RestTemplate#getInterceptors() ClientHttpRequestInterceptors},
        • - *
        • {@link RestTemplate#getClientHttpRequestInitializers() ClientHttpRequestInitializers},
        • - *
        • {@link RestTemplate#getUriTemplateHandler() UriBuilderFactory}, and
        • - *
        • {@linkplain RestTemplate#getErrorHandler() error handler}.
        • + *
        • {@link RestTemplate#getRequestFactory() ClientHttpRequestFactory}
        • + *
        • {@link RestTemplate#getMessageConverters() HttpMessageConverters}
        • + *
        • {@link RestTemplate#getInterceptors() ClientHttpRequestInterceptors}
        • + *
        • {@link RestTemplate#getClientHttpRequestInitializers() ClientHttpRequestInitializers}
        • + *
        • {@link RestTemplate#getUriTemplateHandler() UriBuilderFactory}
        • + *
        • {@linkplain RestTemplate#getErrorHandler() error handler}
        • *
        * @param restTemplate the rest template to base the returned client's * configuration on @@ -184,15 +199,16 @@ static RestClient.Builder builder() { /** * Obtain a {@code RestClient} builder based on the configuration of the - * given {@code RestTemplate}. The returned builder is configured with the - * template's + * given {@code RestTemplate}. + *

        The returned builder is configured with the following attributes of + * the template. *

          - *
        • {@link RestTemplate#getRequestFactory() ClientHttpRequestFactory},
        • - *
        • {@link RestTemplate#getMessageConverters() HttpMessageConverters},
        • - *
        • {@link RestTemplate#getInterceptors() ClientHttpRequestInterceptors},
        • - *
        • {@link RestTemplate#getClientHttpRequestInitializers() ClientHttpRequestInitializers},
        • - *
        • {@link RestTemplate#getUriTemplateHandler() UriBuilderFactory}, and
        • - *
        • {@linkplain RestTemplate#getErrorHandler() error handler}.
        • + *
        • {@link RestTemplate#getRequestFactory() ClientHttpRequestFactory}
        • + *
        • {@link RestTemplate#getMessageConverters() HttpMessageConverters}
        • + *
        • {@link RestTemplate#getInterceptors() ClientHttpRequestInterceptors}
        • + *
        • {@link RestTemplate#getClientHttpRequestInitializers() ClientHttpRequestInitializers}
        • + *
        • {@link RestTemplate#getUriTemplateHandler() UriBuilderFactory}
        • + *
        • {@linkplain RestTemplate#getErrorHandler() error handler}
        • *
        * @param restTemplate the rest template to base the returned builder's * configuration on @@ -228,6 +244,26 @@ interface Builder { */ Builder baseUrl(String baseUrl); + /** + * Configure a base {@code URI} for requests. Effectively a shortcut for: + *
        +		 * URI baseUrl = URI.create("https://abc.go.com/v1");
        +		 * DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(baseUrl.toString());
        +		 * RestClient client = RestClient.builder().uriBuilderFactory(factory).build();
        +		 * 
        + *

        The {@code DefaultUriBuilderFactory} is used to prepare the URL + * for every request with the given base URL, unless the URL request + * for a given URL is absolute in which case the base URL is ignored. + *

        Note: this method is mutually exclusive with + * {@link #uriBuilderFactory(UriBuilderFactory)}. If both are used, the + * {@code baseUrl} value provided here will be ignored. + * @return this builder + * @since 6.2 + * @see DefaultUriBuilderFactory#DefaultUriBuilderFactory(String) + * @see #uriBuilderFactory(UriBuilderFactory) + */ + Builder baseUrl(URI baseUrl); + /** * Configure default URL variable values to use when expanding URI * templates with a {@link Map}. Effectively a shortcut for: @@ -278,6 +314,23 @@ interface Builder { */ Builder defaultHeaders(Consumer headersConsumer); + /** + * Global option to specify a cookie to be added to every request, + * if the request does not already contain such a cookie. + * @param cookie the cookie name + * @param values the cookie values + * @since 6.2 + */ + Builder defaultCookie(String cookie, String... values); + + /** + * Provides access to every {@link #defaultCookie(String, String...)} + * declared so far with the possibility to add, replace, or remove. + * @param cookiesConsumer a function that consumes the cookies map + * @since 6.2 + */ + Builder defaultCookies(Consumer> cookiesConsumer); + /** * Provide a consumer to customize every request being built. * @param defaultRequest the consumer to use for modifying requests @@ -350,7 +403,7 @@ Builder defaultStatusHandler(Predicate statusPredicate, /** * Configure the {@link ClientHttpRequestFactory} to use. This is useful * for plugging in and/or customizing options of the underlying HTTP - * client library (e.g. SSL). + * client library (for example, SSL). *

        If no request factory is specified, {@code RestClient} uses * {@linkplain org.springframework.http.client.HttpComponentsClientHttpRequestFactory Apache Http Client}, * {@linkplain org.springframework.http.client.JettyClientHttpRequestFactory Jetty Http Client} @@ -366,11 +419,22 @@ Builder defaultStatusHandler(Predicate statusPredicate, /** * Configure the message converters for the {@code RestClient} to use. - * @param configurer the configurer to apply + * @param configurer the configurer to apply on the list of default + * {@link HttpMessageConverter} pre-initialized * @return this builder + * @see #messageConverters(List) */ Builder messageConverters(Consumer>> configurer); + /** + * Set the message converters for the {@code RestClient} to use. + * @param messageConverters the list of {@link HttpMessageConverter} to use + * @return this builder + * @since 6.2 + * @see #messageConverters(Consumer) + */ + Builder messageConverters(List> messageConverters); + /** * Configure the {@link io.micrometer.observation.ObservationRegistry} to use * for recording HTTP client observations. @@ -416,20 +480,24 @@ Builder defaultStatusHandler(Predicate statusPredicate, interface UriSpec> { /** - * Specify the URI using an absolute, fully constructed {@link URI}. + * Specify the URI using a fully constructed {@link URI}. + *

        If the given URI is absolute, it is used as given. If it is + * a relative URI, the {@link UriBuilderFactory} configured for + * the client (for example, with a base URI) will be used to + * {@linkplain URI#resolve(URI) resolve} the given URI against. */ S uri(URI uri); /** * Specify the URI for the request using a URI template and URI variables. - *

        If a {@link UriBuilderFactory} was configured for the client (e.g. + *

        If a {@link UriBuilderFactory} was configured for the client (for example, * with a base URI) it will be used to expand the URI template. */ S uri(String uri, Object... uriVariables); /** * Specify the URI for the request using a URI template and URI variables. - *

        If a {@link UriBuilderFactory} was configured for the client (e.g. + *

        If a {@link UriBuilderFactory} was configured for the client (for example, * with a base URI) it will be used to expand the URI template. */ S uri(String uri, Map uriVariables); @@ -470,6 +538,24 @@ interface RequestHeadersSpec> { */ S acceptCharset(Charset... acceptableCharsets); + /** + * Add a cookie with the given name and value. + * @param name the cookie name + * @param value the cookie value + * @return this builder + * @since 6.2 + */ + S cookie(String name, String value); + + /** + * Provides access to every cookie declared so far with the possibility + * to add, replace, or remove values. + * @param cookiesConsumer the consumer to provide access to + * @return this builder + * @since 6.2 + */ + S cookies(Consumer> cookiesConsumer); + /** * Set the value of the {@code If-Modified-Since} header. * @param ifModifiedSince the new value of the header @@ -500,6 +586,24 @@ interface RequestHeadersSpec> { */ S headers(Consumer headersConsumer); + /** + * Set the attribute with the given name to the given value. + * @param name the name of the attribute to add + * @param value the value of the attribute to add + * @return this builder + * @since 6.2 + */ + S attribute(String name, Object value); + + /** + * Provides access to every attribute declared so far with the + * possibility to add, replace, or remove values. + * @param attributesConsumer the consumer to provide access to + * @return this builder + * @since 6.2 + */ + S attributes(Consumer> attributesConsumer); + /** * Callback for access to the {@link ClientHttpRequest} that in turn * provides access to the native request of the underlying HTTP library. @@ -512,8 +616,10 @@ interface RequestHeadersSpec> { S httpRequest(Consumer requestConsumer); /** - * Proceed to declare how to extract the response. For example to extract - * a {@link ResponseEntity} with status, headers, and body: + * Enter the retrieve workflow and use the returned {@link ResponseSpec} + * to select from a number of built-in options to extract the response. + * For example: + * *

         		 * ResponseEntity<Person> entity = client.get()
         		 *     .uri("/persons/1")
        @@ -529,12 +635,17 @@ interface RequestHeadersSpec> {
         		 *     .retrieve()
         		 *     .body(Person.class);
         		 * 
        + * Note that this method does not actually execute the request until you + * call one of the returned {@link ResponseSpec}. Use the + * {@link #exchange(ExchangeFunction)} variants if you need to separate + * request execution from response extraction. *

        By default, 4xx response code result in a * {@link HttpClientErrorException} and 5xx response codes in a * {@link HttpServerErrorException}. To customize error handling, use * {@link ResponseSpec#onStatus(Predicate, ResponseSpec.ErrorHandler) onStatus} handlers. * @return {@code ResponseSpec} to specify how to decode the body */ + @CheckReturnValue ResponseSpec retrieve(); /** diff --git a/spring-web/src/main/java/org/springframework/web/client/RestClientException.java b/spring-web/src/main/java/org/springframework/web/client/RestClientException.java index c5d969be827d..8793a048e6e4 100644 --- a/spring-web/src/main/java/org/springframework/web/client/RestClientException.java +++ b/spring-web/src/main/java/org/springframework/web/client/RestClientException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2025 the original author 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,15 +17,17 @@ package org.springframework.web.client; import org.springframework.core.NestedRuntimeException; -import org.springframework.http.client.ClientHttpResponse; import org.springframework.lang.Nullable; /** - * Base class for exceptions thrown by {@link RestTemplate} in case a request - * fails because of a server error response, as determined via - * {@link ResponseErrorHandler#hasError(ClientHttpResponse)}, failure to decode + * Base class for exceptions thrown by {@link RestClient} and {@link RestTemplate} + * in case a request fails because of a server error response, a failure to decode * the response, or a low level I/O error. * + *

        Server error responses are determined by + * {@link RestClient.ResponseSpec#onStatus status handlers} for {@code RestClient}, + * and by {@link ResponseErrorHandler} for {@code RestTemplate}. + * * @author Arjen Poutsma * @since 3.0 */ 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 440fad582a5e..5429c251963b 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 @@ -374,7 +374,7 @@ ResponseEntity postForEntity(URI url, @Nullable Object request, Class *

        The {@code request} parameter can be a {@link HttpEntity} in order to * add additional HTTP headers to the request. *

        NOTE: The standard JDK HTTP library does not support HTTP PATCH. - * You need to use e.g. the Apache HttpComponents request factory. + * You need to use, for example, the Apache HttpComponents request factory. * @param url the URL * @param request the object to be PATCHed (may be {@code null}) * @param responseType the type of the return value @@ -396,7 +396,7 @@ T patchForObject(String url, @Nullable Object request, Class responseType *

        The {@code request} parameter can be a {@link HttpEntity} in order to * add additional HTTP headers to the request. *

        NOTE: The standard JDK HTTP library does not support HTTP PATCH. - * You need to use e.g. the Apache HttpComponents request factory. + * You need to use, for example, the Apache HttpComponents request factory. * @param url the URL * @param request the object to be PATCHed (may be {@code null}) * @param responseType the type of the return value @@ -417,7 +417,7 @@ T patchForObject(String url, @Nullable Object request, Class responseType *

        The {@code request} parameter can be a {@link HttpEntity} in order to * add additional HTTP headers to the request. *

        NOTE: The standard JDK HTTP library does not support HTTP PATCH. - * You need to use e.g. the Apache HttpComponents request factory. + * You need to use, for example, the Apache HttpComponents request factory. * @param url the URL * @param request the object to be PATCHed (may be {@code null}) * @param responseType the type of the return value diff --git a/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java b/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java index 78cace8148ef..0929816d7152 100644 --- a/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java +++ b/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,6 +33,7 @@ import io.micrometer.observation.ObservationRegistry; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -52,6 +53,7 @@ import org.springframework.http.converter.GenericHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.ResourceHttpMessageConverter; +import org.springframework.http.converter.SmartHttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.http.converter.cbor.KotlinSerializationCborHttpMessageConverter; import org.springframework.http.converter.cbor.MappingJackson2CborHttpMessageConverter; @@ -66,6 +68,7 @@ import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter; import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; +import org.springframework.http.converter.yaml.MappingJackson2YamlHttpMessageConverter; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -108,6 +111,7 @@ * @author Juergen Hoeller * @author Sam Brannen * @author Sebastien Deleuze + * @author Hyoungjune Kim * @since 3.0 * @see HttpMessageConverter * @see RequestCallback @@ -128,6 +132,8 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat private static final boolean jackson2CborPresent; + private static final boolean jackson2YamlPresent; + private static final boolean gsonPresent; private static final boolean jsonbPresent; @@ -149,6 +155,7 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat jackson2XmlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader); jackson2SmilePresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader); jackson2CborPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", classLoader); + jackson2YamlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.yaml.YAMLFactory", classLoader); gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); jsonbPresent = ClassUtils.isPresent("jakarta.json.bind.Jsonb", classLoader); kotlinSerializationCborPresent = ClassUtils.isPresent("kotlinx.serialization.cbor.Cbor", classLoader); @@ -172,7 +179,7 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat /** - * Create a new instance of the {@link RestTemplate} using default settings. + * Create a new instance with default settings. * Default {@link HttpMessageConverter HttpMessageConverters} are initialized. */ public RestTemplate() { @@ -222,12 +229,16 @@ else if (kotlinSerializationCborPresent) { this.messageConverters.add(new KotlinSerializationCborHttpMessageConverter()); } + if (jackson2YamlPresent) { + this.messageConverters.add(new MappingJackson2YamlHttpMessageConverter()); + } + updateErrorHandlerConverters(); this.uriTemplateHandler = initUriTemplateHandler(); } /** - * Create a new instance of the {@link RestTemplate} based on the given {@link ClientHttpRequestFactory}. + * Create a new instance with the given {@link ClientHttpRequestFactory}. * @param requestFactory the HTTP request factory to use * @see org.springframework.http.client.SimpleClientHttpRequestFactory * @see org.springframework.http.client.HttpComponentsClientHttpRequestFactory @@ -238,9 +249,8 @@ public RestTemplate(ClientHttpRequestFactory requestFactory) { } /** - * Create a new instance of the {@link RestTemplate} using the given list of - * {@link HttpMessageConverter} to use. - * @param messageConverters the list of {@link HttpMessageConverter} to use + * Create a new instance with the given message converters. + * @param messageConverters the list of converters to use * @since 3.2.7 */ public RestTemplate(List> messageConverters) { @@ -1021,13 +1031,15 @@ public void doWithRequest(ClientHttpRequest request) throws IOException { } private boolean canReadResponse(Type responseType, HttpMessageConverter converter) { - Class responseClass = (responseType instanceof Class clazz ? clazz : null); - if (responseClass != null) { - return converter.canRead(responseClass, null); - } - else if (converter instanceof GenericHttpMessageConverter genericConverter) { + if (converter instanceof GenericHttpMessageConverter genericConverter) { return genericConverter.canRead(responseType, null, null); } + else if (converter instanceof SmartHttpMessageConverter smartConverter) { + return smartConverter.canRead(ResolvableType.forType(responseType), null); + } + else if (responseType instanceof Class responseClass) { + return converter.canRead(responseClass, null); + } return false; } @@ -1105,6 +1117,17 @@ public void doWithRequest(ClientHttpRequest httpRequest) throws IOException { return; } } + else if (messageConverter instanceof SmartHttpMessageConverter smartConverter) { + ResolvableType resolvableType = ResolvableType.forType(requestBodyType); + if (smartConverter.canWrite(resolvableType, requestBodyClass, requestContentType)) { + if (!requestHeaders.isEmpty()) { + requestHeaders.forEach((key, values) -> httpHeaders.put(key, new ArrayList<>(values))); + } + logBody(requestBody, requestContentType, smartConverter); + smartConverter.write(requestBody, resolvableType, requestContentType, httpRequest, null); + return; + } + } else if (messageConverter.canWrite(requestBodyClass, requestContentType)) { if (!requestHeaders.isEmpty()) { requestHeaders.forEach((key, values) -> httpHeaders.put(key, new ArrayList<>(values))); diff --git a/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java b/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java index 403d7f71562e..67c4ecca9061 100644 --- a/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java +++ b/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java @@ -56,7 +56,7 @@ private RestClientAdapter(RestClient restClient) { @Override public boolean supportsRequestAttributes() { - return false; + return true; } @Override @@ -121,6 +121,8 @@ else if (values.getUriTemplate() != null) { bodySpec.header(HttpHeaders.COOKIE, String.join("; ", cookies)); } + bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes())); + if (values.getBodyValue() != null) { bodySpec.body(values.getBodyValue()); } diff --git a/spring-web/src/main/java/org/springframework/web/context/AbstractContextLoaderInitializer.java b/spring-web/src/main/java/org/springframework/web/context/AbstractContextLoaderInitializer.java index 01fae7c0aeac..065acbadb260 100644 --- a/spring-web/src/main/java/org/springframework/web/context/AbstractContextLoaderInitializer.java +++ b/spring-web/src/main/java/org/springframework/web/context/AbstractContextLoaderInitializer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2024 the original author 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 void onStartup(ServletContext servletContext) throws ServletException { protected void registerContextLoaderListener(ServletContext servletContext) { WebApplicationContext rootAppContext = createRootApplicationContext(); if (rootAppContext != null) { - ContextLoaderListener listener = new ContextLoaderListener(rootAppContext); + ContextLoaderListener listener = new ContextLoaderListener(rootAppContext, servletContext); listener.setContextInitializers(getRootApplicationContextInitializers()); servletContext.addListener(listener); } diff --git a/spring-web/src/main/java/org/springframework/web/context/ContextLoader.java b/spring-web/src/main/java/org/springframework/web/context/ContextLoader.java index 1f5c6f42de7c..83c9297bd698 100644 --- a/spring-web/src/main/java/org/springframework/web/context/ContextLoader.java +++ b/spring-web/src/main/java/org/springframework/web/context/ContextLoader.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +54,9 @@ * *

        Processes a {@link #CONFIG_LOCATION_PARAM "contextConfigLocation"} context-param * and passes its value to the context instance, parsing it into potentially multiple - * file paths which can be separated by any number of commas and spaces, e.g. + * file paths which can be separated by any number of commas and spaces, for example, * "WEB-INF/applicationContext1.xml, WEB-INF/applicationContext2.xml". - * Ant-style path patterns are supported as well, e.g. + * Ant-style path patterns are supported as well, for example, * "WEB-INF/*Context.xml,WEB-INF/spring*.xml" or "WEB-INF/**/*Context.xml". * If not explicitly specified, the context implementation is supposed to use a * default location (with XmlWebApplicationContext: "/WEB-INF/applicationContext.xml"). @@ -152,7 +152,7 @@ public class ContextLoader { * The root WebApplicationContext instance that this loader manages. */ @Nullable - private WebApplicationContext context; + private WebApplicationContext rootContext; /** Actual ApplicationContextInitializer instances to apply to the context. */ private final List> contextInitializers = @@ -205,12 +205,12 @@ public ContextLoader() { * WebApplicationContext#ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE} and subclasses are * free to call the {@link #closeWebApplicationContext} method on container shutdown * to close the application context. - * @param context the application context to manage + * @param rootContext the application context to manage * @see #initWebApplicationContext(ServletContext) * @see #closeWebApplicationContext(ServletContext) */ - public ContextLoader(WebApplicationContext context) { - this.context = context; + public ContextLoader(WebApplicationContext rootContext) { + this.rootContext = rootContext; } @@ -259,10 +259,10 @@ public WebApplicationContext initWebApplicationContext(ServletContext servletCon try { // Store context in local instance variable, to guarantee that // it is available on ServletContext shutdown. - if (this.context == null) { - this.context = createWebApplicationContext(servletContext); + if (this.rootContext == null) { + this.rootContext = createWebApplicationContext(servletContext); } - if (this.context instanceof ConfigurableWebApplicationContext cwac && !cwac.isActive()) { + if (this.rootContext instanceof ConfigurableWebApplicationContext cwac && !cwac.isActive()) { // The context has not yet been refreshed -> provide services such as // setting the parent context, setting the application context id, etc if (cwac.getParent() == null) { @@ -273,14 +273,14 @@ public WebApplicationContext initWebApplicationContext(ServletContext servletCon } configureAndRefreshWebApplicationContext(cwac, servletContext); } - servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context); + servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.rootContext); ClassLoader ccl = Thread.currentThread().getContextClassLoader(); if (ccl == ContextLoader.class.getClassLoader()) { - currentContext = this.context; + currentContext = this.rootContext; } else if (ccl != null) { - currentContextPerThread.put(ccl, this.context); + currentContextPerThread.put(ccl, this.rootContext); } if (logger.isInfoEnabled()) { @@ -288,7 +288,7 @@ else if (ccl != null) { logger.info("Root WebApplicationContext initialized in " + elapsedTime + " ms"); } - return this.context; + return this.rootContext; } catch (RuntimeException | Error ex) { logger.error("Context initialization failed", ex); @@ -506,7 +506,7 @@ protected ApplicationContext loadParentContext(ServletContext servletContext) { public void closeWebApplicationContext(ServletContext servletContext) { servletContext.log("Closing Spring root WebApplicationContext"); try { - if (this.context instanceof ConfigurableWebApplicationContext cwac) { + if (this.rootContext instanceof ConfigurableWebApplicationContext cwac) { cwac.close(); } } diff --git a/spring-web/src/main/java/org/springframework/web/context/ContextLoaderListener.java b/spring-web/src/main/java/org/springframework/web/context/ContextLoaderListener.java index 8a3aa2e79c74..e065e415261b 100644 --- a/spring-web/src/main/java/org/springframework/web/context/ContextLoaderListener.java +++ b/spring-web/src/main/java/org/springframework/web/context/ContextLoaderListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +16,12 @@ package org.springframework.web.context; +import jakarta.servlet.ServletContext; import jakarta.servlet.ServletContextEvent; import jakarta.servlet.ServletContextListener; +import org.springframework.lang.Nullable; + /** * Bootstrap listener to start up and shut down Spring's root {@link WebApplicationContext}. * Simply delegates to {@link ContextLoader} as well as to {@link ContextCleanupListener}. @@ -36,6 +39,10 @@ */ public class ContextLoaderListener extends ContextLoader implements ServletContextListener { + @Nullable + private ServletContext servletContext; + + /** * Create a new {@code ContextLoaderListener} that will create a web application * context based on the "contextClass" and "contextConfigLocation" servlet @@ -56,6 +63,19 @@ public class ContextLoaderListener extends ContextLoader implements ServletConte public ContextLoaderListener() { } + /** + * Create a new {@code ContextLoaderListener} with the given application context, + * initializing it with the {@link ServletContextEvent}-provided + * {@link ServletContext} reference which is spec-restricted in terms of capabilities. + *

        It is generally preferable to initialize the application context with a + * {@link org.springframework.web.WebApplicationInitializer#onStartup}-given reference + * which is usually fully capable. + * @see #ContextLoaderListener(WebApplicationContext, ServletContext) + */ + public ContextLoaderListener(WebApplicationContext rootContext) { + super(rootContext); + } + /** * Create a new {@code ContextLoaderListener} with the given application context. This * constructor is useful in Servlet initializers where instance-based registration of @@ -85,12 +105,15 @@ public ContextLoaderListener() { * WebApplicationContext#ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE} and the Spring * application context will be closed when the {@link #contextDestroyed} lifecycle * method is invoked on this listener. - * @param context the application context to manage + * @param rootContext the application context to manage + * @param servletContext the ServletContext to initialize with + * @since 6.2 * @see #contextInitialized(ServletContextEvent) * @see #contextDestroyed(ServletContextEvent) */ - public ContextLoaderListener(WebApplicationContext context) { - super(context); + public ContextLoaderListener(WebApplicationContext rootContext, ServletContext servletContext) { + super(rootContext); + this.servletContext = servletContext; } @@ -99,7 +122,8 @@ public ContextLoaderListener(WebApplicationContext context) { */ @Override public void contextInitialized(ServletContextEvent event) { - initWebApplicationContext(event.getServletContext()); + ServletContext scToUse = getServletContextToUse(event); + initWebApplicationContext(scToUse); } @@ -108,8 +132,17 @@ public void contextInitialized(ServletContextEvent event) { */ @Override public void contextDestroyed(ServletContextEvent event) { - closeWebApplicationContext(event.getServletContext()); - ContextCleanupListener.cleanupAttributes(event.getServletContext()); + ServletContext scToUse = getServletContextToUse(event); + closeWebApplicationContext(scToUse); + ContextCleanupListener.cleanupAttributes(scToUse); + } + + /** + * Preferably use a fully-capable local ServletContext instead of + * the spec-restricted ServletContextEvent-provided reference. + */ + private ServletContext getServletContextToUse(ServletContextEvent event) { + return (this.servletContext != null ? this.servletContext : event.getServletContext()); } } diff --git a/spring-web/src/main/java/org/springframework/web/context/request/AsyncWebRequestInterceptor.java b/spring-web/src/main/java/org/springframework/web/context/request/AsyncWebRequestInterceptor.java index 7582388b12b0..2ac418a7f5a8 100644 --- a/spring-web/src/main/java/org/springframework/web/context/request/AsyncWebRequestInterceptor.java +++ b/spring-web/src/main/java/org/springframework/web/context/request/AsyncWebRequestInterceptor.java @@ -22,7 +22,7 @@ * *

        When a handler starts asynchronous request handling, the DispatcherServlet * exits without invoking {@code postHandle} and {@code afterCompletion}, as it - * normally does, since the results of request handling (e.g. ModelAndView) are + * normally does, since the results of request handling (for example, ModelAndView) are * not available in the current thread and handling is not yet complete. * In such scenarios, the {@link #afterConcurrentHandlingStarted(WebRequest)} * method is invoked instead allowing implementations to perform tasks such as diff --git a/spring-web/src/main/java/org/springframework/web/context/request/FacesRequestAttributes.java b/spring-web/src/main/java/org/springframework/web/context/request/FacesRequestAttributes.java index 24961bb68e5d..df96f1239800 100644 --- a/spring-web/src/main/java/org/springframework/web/context/request/FacesRequestAttributes.java +++ b/spring-web/src/main/java/org/springframework/web/context/request/FacesRequestAttributes.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-web/src/main/java/org/springframework/web/context/request/RequestAttributesThreadLocalAccessor.java b/spring-web/src/main/java/org/springframework/web/context/request/RequestAttributesThreadLocalAccessor.java new file mode 100644 index 000000000000..fcaf2b4549c0 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/context/request/RequestAttributesThreadLocalAccessor.java @@ -0,0 +1,148 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.context.request; + +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +import io.micrometer.context.ThreadLocalAccessor; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.lang.Nullable; + +/** + * Adapt {@link RequestContextHolder} to the {@link ThreadLocalAccessor} contract + * to assist the Micrometer Context Propagation library with + * {@link RequestAttributes} propagation. + * + * @author Tadaya Tsuyukubo + * @author Rossen Stoyanchev + * @since 6.2 + */ +public class RequestAttributesThreadLocalAccessor implements ThreadLocalAccessor { + + /** + * Key under which this accessor is registered in + * {@link io.micrometer.context.ContextRegistry}. + */ + public static final String KEY = RequestAttributesThreadLocalAccessor.class.getName() + ".KEY"; + + @Override + public Object key() { + return KEY; + } + + @Override + @Nullable + public RequestAttributes getValue() { + RequestAttributes request = RequestContextHolder.getRequestAttributes(); + if (request instanceof ServletRequestAttributes sra && !(sra instanceof SnapshotServletRequestAttributes)) { + request = new SnapshotServletRequestAttributes(sra); + } + return request; + } + + @Override + public void setValue(RequestAttributes value) { + RequestContextHolder.setRequestAttributes(value); + } + + @Override + public void setValue() { + RequestContextHolder.resetRequestAttributes(); + } + + + /** + * ServletRequestAttributes that takes another instance, and makes a copy of the + * request attributes at present to provide extended read access during async + * handling when the DispatcherServlet has exited from the initial REQUEST dispatch + * and marked the request {@link ServletRequestAttributes#requestCompleted()}. + *

        Note that beyond access to request attributes, there is no attempt to support + * setting or removing request attributes, nor to access session attributes after + * the initial REQUEST dispatch has exited. + */ + private static final class SnapshotServletRequestAttributes extends ServletRequestAttributes { + + private final ServletRequestAttributes delegate; + + private final Map attributeMap; + + public SnapshotServletRequestAttributes(ServletRequestAttributes requestAttributes) { + super(requestAttributes.getRequest(), requestAttributes.getResponse()); + this.delegate = requestAttributes; + this.attributeMap = getAttributes(requestAttributes.getRequest()); + } + + private static Map getAttributes(HttpServletRequest request) { + Map map = new HashMap<>(); + Enumeration names = request.getAttributeNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + map.put(name, request.getAttribute(name)); + } + return map; + } + + // Delegate methods that check isRequestActive() + + @Nullable + @Override + public Object getAttribute(String name, int scope) { + if (scope == RequestAttributes.SCOPE_REQUEST && !this.delegate.isRequestActive()) { + return this.attributeMap.get(name); + } + try { + return this.delegate.getAttribute(name, scope); + } + catch (IllegalStateException ex) { + if (scope == RequestAttributes.SCOPE_REQUEST) { + return this.attributeMap.get(name); + } + throw ex; + } + } + + @Override + public String[] getAttributeNames(int scope) { + if (scope == RequestAttributes.SCOPE_REQUEST && !this.delegate.isRequestActive()) { + return this.attributeMap.keySet().toArray(new String[0]); + } + try { + return this.delegate.getAttributeNames(scope); + } + catch (IllegalStateException ex) { + if (scope == RequestAttributes.SCOPE_REQUEST) { + return this.attributeMap.keySet().toArray(new String[0]); + } + throw ex; + } + } + + @Override + public void setAttribute(String name, Object value, int scope) { + this.delegate.setAttribute(name, value, scope); + } + + @Override + public void removeAttribute(String name, int scope) { + this.delegate.removeAttribute(name, scope); + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/context/request/RequestContextListener.java b/spring-web/src/main/java/org/springframework/web/context/request/RequestContextListener.java index ecca2b235f6a..2ecee077b313 100644 --- a/spring-web/src/main/java/org/springframework/web/context/request/RequestContextListener.java +++ b/spring-web/src/main/java/org/springframework/web/context/request/RequestContextListener.java @@ -30,9 +30,9 @@ *

        Alternatively, Spring's {@link org.springframework.web.filter.RequestContextFilter} * and Spring's {@link org.springframework.web.servlet.DispatcherServlet} also expose * the same request context to the current thread. In contrast to this listener, - * advanced options are available there (e.g. "threadContextInheritable"). + * advanced options are available there (for example, "threadContextInheritable"). * - *

        This listener is mainly for use with third-party servlets, e.g. the JSF FacesServlet. + *

        This listener is mainly for use with third-party servlets, for example, the JSF FacesServlet. * Within Spring's own web support, DispatcherServlet's processing is perfectly sufficient. * * @author Juergen Hoeller diff --git a/spring-web/src/main/java/org/springframework/web/context/request/ServletRequestAttributes.java b/spring-web/src/main/java/org/springframework/web/context/request/ServletRequestAttributes.java index a767b0b98f52..52cddccba745 100644 --- a/spring-web/src/main/java/org/springframework/web/context/request/ServletRequestAttributes.java +++ b/spring-web/src/main/java/org/springframework/web/context/request/ServletRequestAttributes.java @@ -143,6 +143,7 @@ private HttpSession obtainSession() { @Override + @Nullable public Object getAttribute(String name, int scope) { if (scope == SCOPE_REQUEST) { if (!isRequestActive()) { @@ -242,6 +243,7 @@ public void registerDestructionCallback(String name, Runnable callback, int scop } @Override + @Nullable public Object resolveReference(String key) { if (REFERENCE_REQUEST.equals(key)) { return this.request; diff --git a/spring-web/src/main/java/org/springframework/web/context/request/ServletWebRequest.java b/spring-web/src/main/java/org/springframework/web/context/request/ServletWebRequest.java index 462fd89cf7fc..64690c9495f7 100644 --- a/spring-web/src/main/java/org/springframework/web/context/request/ServletWebRequest.java +++ b/spring-web/src/main/java/org/springframework/web/context/request/ServletWebRequest.java @@ -91,16 +91,19 @@ public Object getNativeRequest() { } @Override + @Nullable public Object getNativeResponse() { return getResponse(); } @Override + @Nullable public T getNativeRequest(@Nullable Class requiredType) { return WebUtils.getNativeRequest(getRequest(), requiredType); } @Override + @Nullable public T getNativeResponse(@Nullable Class requiredType) { HttpServletResponse response = getResponse(); return (response != null ? WebUtils.getNativeResponse(response, requiredType) : null); @@ -199,10 +202,15 @@ public boolean checkNotModified(String etag) { @Override public boolean checkNotModified(@Nullable String etag, long lastModifiedTimestamp) { + if (this.notModified) { + return true; + } + HttpServletResponse response = getResponse(); - if (this.notModified || (response != null && HttpStatus.OK.value() != response.getStatus())) { - return this.notModified; + if (response != null && HttpStatus.OK.value() != response.getStatus()) { + return false; } + // Evaluate conditions in order of precedence. // See https://datatracker.ietf.org/doc/html/rfc9110#section-13.2.2 if (validateIfMatch(etag)) { @@ -210,7 +218,7 @@ public boolean checkNotModified(@Nullable String etag, long lastModifiedTimestam return this.notModified; } // 2) If-Unmodified-Since - else if (validateIfUnmodifiedSince(lastModifiedTimestamp)) { + if (validateIfUnmodifiedSince(lastModifiedTimestamp)) { updateResponseStateChanging(etag, lastModifiedTimestamp); return this.notModified; } @@ -244,23 +252,18 @@ private boolean validateIfNoneMatch(@Nullable String etag) { return true; } - private boolean matchRequestedETags(Enumeration requestedETags, @Nullable String etag, boolean weakCompare) { - etag = padEtagIfNecessary(etag); - while (requestedETags.hasMoreElements()) { - // Compare weak/strong ETags as per https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.3 - for (ETag requestedETag : ETag.parse(requestedETags.nextElement())) { - // only consider "lost updates" checks for unsafe HTTP methods - if (requestedETag.isWildcard() && StringUtils.hasLength(etag) - && !SAFE_METHODS.contains(getRequest().getMethod())) { - return false; - } - if (weakCompare) { - if (etagWeakMatch(etag, requestedETag.formattedTag())) { + private boolean matchRequestedETags(Enumeration requestedETags, @Nullable String tag, boolean weakCompare) { + if (StringUtils.hasLength(tag)) { + ETag eTag = ETag.create(tag); + boolean isNotSafeMethod = !SAFE_METHODS.contains(getRequest().getMethod()); + while (requestedETags.hasMoreElements()) { + // Compare weak/strong ETags as per https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.3 + for (ETag requestedETag : ETag.parse(requestedETags.nextElement())) { + // only consider "lost updates" checks for unsafe HTTP methods + if (requestedETag.isWildcard() && isNotSafeMethod) { return false; } - } - else { - if (etagStrongMatch(etag, requestedETag.formattedTag())) { + if (requestedETag.compare(eTag, !weakCompare)) { return false; } } @@ -269,37 +272,6 @@ private boolean matchRequestedETags(Enumeration requestedETags, @Nullabl return true; } - @Nullable - private String padEtagIfNecessary(@Nullable String etag) { - if (!StringUtils.hasLength(etag)) { - return etag; - } - if ((etag.startsWith("\"") || etag.startsWith("W/\"")) && etag.endsWith("\"")) { - return etag; - } - return "\"" + etag + "\""; - } - - private boolean etagStrongMatch(@Nullable String first, @Nullable String second) { - if (!StringUtils.hasLength(first) || first.startsWith("W/")) { - return false; - } - return first.equals(second); - } - - private boolean etagWeakMatch(@Nullable String first, @Nullable String second) { - if (!StringUtils.hasLength(first) || !StringUtils.hasLength(second)) { - return false; - } - if (first.startsWith("W/")) { - first = first.substring(2); - } - if (second.startsWith("W/")) { - second = second.substring(2); - } - return first.equals(second); - } - private void updateResponseStateChanging(@Nullable String etag, long lastModifiedTimestamp) { if (this.notModified && getResponse() != null) { getResponse().setStatus(HttpStatus.PRECONDITION_FAILED.value()); @@ -343,13 +315,13 @@ private void updateResponseIdempotent(@Nullable String etag, long lastModifiedTi } } - private void addCachingResponseHeaders(@Nullable String etag, long lastModifiedTimestamp) { + private void addCachingResponseHeaders(@Nullable String eTag, long lastModifiedTimestamp) { if (getResponse() != null && SAFE_METHODS.contains(getRequest().getMethod())) { if (lastModifiedTimestamp > 0 && parseDateValue(getResponse().getHeader(HttpHeaders.LAST_MODIFIED)) == -1) { getResponse().setDateHeader(HttpHeaders.LAST_MODIFIED, lastModifiedTimestamp); } - if (StringUtils.hasLength(etag) && getResponse().getHeader(HttpHeaders.ETAG) == null) { - getResponse().setHeader(HttpHeaders.ETAG, padEtagIfNecessary(etag)); + if (StringUtils.hasLength(eTag) && getResponse().getHeader(HttpHeaders.ETAG) == null) { + getResponse().setHeader(HttpHeaders.ETAG, ETag.quoteETagIfNecessary(eTag)); } } } diff --git a/spring-web/src/main/java/org/springframework/web/context/request/WebRequest.java b/spring-web/src/main/java/org/springframework/web/context/request/WebRequest.java index e1d4899e9bd4..f3b597d0e4d4 100644 --- a/spring-web/src/main/java/org/springframework/web/context/request/WebRequest.java +++ b/spring-web/src/main/java/org/springframework/web/context/request/WebRequest.java @@ -223,7 +223,7 @@ public interface WebRequest extends RequestAttributes { * also with conditional POST/PUT/DELETE requests. *

        Note: The HTTP specification recommends * setting both ETag and Last-Modified values, but you can also - * use {@code #checkNotModified(String)} or + * use {@link #checkNotModified(String)} or * {@link #checkNotModified(long)}. * @param etag the entity tag that the application determined * for the underlying resource. This parameter will be padded diff --git a/spring-web/src/main/java/org/springframework/web/context/request/async/DeferredResult.java b/spring-web/src/main/java/org/springframework/web/context/request/async/DeferredResult.java index 3fbd694e8b76..d5e16c10666c 100644 --- a/spring-web/src/main/java/org/springframework/web/context/request/async/DeferredResult.java +++ b/spring-web/src/main/java/org/springframework/web/context/request/async/DeferredResult.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -288,65 +288,75 @@ public boolean setErrorResult(Object result) { } - final DeferredResultProcessingInterceptor getInterceptor() { - return new DeferredResultProcessingInterceptor() { - @Override - public boolean handleTimeout(NativeWebRequest request, DeferredResult deferredResult) { - boolean continueProcessing = true; - try { - if (timeoutCallback != null) { - timeoutCallback.run(); - } - } - finally { - Object value = timeoutResult.get(); - if (value != RESULT_NONE) { - continueProcessing = false; - try { - setResultInternal(value); - } - catch (Throwable ex) { - logger.debug("Failed to handle timeout result", ex); - } - } + final DeferredResultProcessingInterceptor getLifecycleInterceptor() { + return new LifecycleInterceptor(); + } + + + /** + * Handles a DeferredResult value when set. + */ + @FunctionalInterface + public interface DeferredResultHandler { + + void handleResult(@Nullable Object result); + } + + + /** + * Instance interceptor to receive Servlet container notifications. + */ + private class LifecycleInterceptor implements DeferredResultProcessingInterceptor { + + @Override + public boolean handleTimeout(NativeWebRequest request, DeferredResult result) { + boolean continueProcessing = true; + try { + if (timeoutCallback != null) { + timeoutCallback.run(); } - return continueProcessing; } - @Override - public boolean handleError(NativeWebRequest request, DeferredResult deferredResult, Throwable t) { - try { - if (errorCallback != null) { - errorCallback.accept(t); - } - } - finally { + finally { + Object value = timeoutResult.get(); + if (value != RESULT_NONE) { + continueProcessing = false; try { - setResultInternal(t); + setResultInternal(value); } catch (Throwable ex) { - logger.debug("Failed to handle error result", ex); + logger.debug("Failed to handle timeout result", ex); } } - return false; } - @Override - public void afterCompletion(NativeWebRequest request, DeferredResult deferredResult) { - expired = true; - if (completionCallback != null) { - completionCallback.run(); + return continueProcessing; + } + + @Override + public boolean handleError(NativeWebRequest request, DeferredResult result, Throwable t) { + try { + if (errorCallback != null) { + errorCallback.accept(t); } } - }; - } - + finally { + try { + setResultInternal(t); + } + catch (Throwable ex) { + logger.debug("Failed to handle error result", ex); + } + } + return false; + } - /** - * Handles a DeferredResult value when set. - */ - @FunctionalInterface - public interface DeferredResultHandler { + @Override + public void afterCompletion(NativeWebRequest request, DeferredResult result) { + expired = true; + if (completionCallback != null) { + completionCallback.run(); + } + } - void handleResult(@Nullable Object result); } } diff --git a/spring-web/src/main/java/org/springframework/web/context/request/async/DeferredResultProcessingInterceptor.java b/spring-web/src/main/java/org/springframework/web/context/request/async/DeferredResultProcessingInterceptor.java index e37c377fdabf..8c79c180da29 100644 --- a/spring-web/src/main/java/org/springframework/web/context/request/async/DeferredResultProcessingInterceptor.java +++ b/spring-web/src/main/java/org/springframework/web/context/request/async/DeferredResultProcessingInterceptor.java @@ -22,7 +22,7 @@ /** * Intercepts concurrent request handling, where the concurrent result is * obtained by waiting for a {@link DeferredResult} to be set from a thread - * chosen by the application (e.g. in response to some external event). + * chosen by the application (for example, in response to some external event). * *

        A {@code DeferredResultProcessingInterceptor} is invoked before the start * of async processing, after the {@code DeferredResult} is set as well as on diff --git a/spring-web/src/main/java/org/springframework/web/context/request/async/StandardServletAsyncWebRequest.java b/spring-web/src/main/java/org/springframework/web/context/request/async/StandardServletAsyncWebRequest.java index 96dc905c537a..9cad8c359176 100644 --- a/spring-web/src/main/java/org/springframework/web/context/request/async/StandardServletAsyncWebRequest.java +++ b/spring-web/src/main/java/org/springframework/web/context/request/async/StandardServletAsyncWebRequest.java @@ -85,6 +85,7 @@ public StandardServletAsyncWebRequest(HttpServletRequest request, HttpServletRes * @param previousRequest the existing request from the last dispatch * @since 5.3.33 */ + @SuppressWarnings("NullAway") StandardServletAsyncWebRequest(HttpServletRequest request, HttpServletResponse response, @Nullable StandardServletAsyncWebRequest previousRequest) { @@ -223,7 +224,7 @@ private int tryObtainLock() { return 0; } - // Do not wait indefinitely, stop if we moved on from ASYNC state (e.g. to ERROR), + // Do not wait indefinitely, stop if we moved on from ASYNC state (for example, to ERROR), // helps to avoid ABBA deadlock with onError callback while (this.state == State.ASYNC) { @@ -276,6 +277,7 @@ public void setAsyncWebRequest(StandardServletAsyncWebRequest asyncWebRequest) { } @Override + @SuppressWarnings("NullAway") public ServletOutputStream getOutputStream() throws IOException { int level = obtainLockOrRaiseException(); try { @@ -295,6 +297,7 @@ public ServletOutputStream getOutputStream() throws IOException { } @Override + @SuppressWarnings("NullAway") public PrintWriter getWriter() throws IOException { int level = obtainLockOrRaiseException(); try { diff --git a/spring-web/src/main/java/org/springframework/web/context/request/async/WebAsyncManager.java b/spring-web/src/main/java/org/springframework/web/context/request/async/WebAsyncManager.java index 3fbbf15f1046..00479d483e92 100644 --- a/spring-web/src/main/java/org/springframework/web/context/request/async/WebAsyncManager.java +++ b/spring-web/src/main/java/org/springframework/web/context/request/async/WebAsyncManager.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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.util.Assert; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.async.DeferredResult.DeferredResultHandler; +import org.springframework.web.util.DisconnectedClientHelper; /** * The central class for managing asynchronous request processing, mainly intended @@ -106,7 +107,7 @@ public final class WebAsyncManager { /** * Configure the {@link AsyncWebRequest} to use. This property may be set * more than once during a single request to accurately reflect the current - * state of the request (e.g. following a forward, request/response + * state of the request (for example, following a forward, request/response * wrapping, etc). However, it should not be set while concurrent handling * is in progress, i.e. while {@link #isConcurrentHandlingStarted()} is * {@code true}. @@ -306,6 +307,7 @@ public void startCallableProcessing(Callable callable, Object... processingCo * via {@link #getConcurrentResultContext()} * @throws Exception if concurrent processing failed to start */ + @SuppressWarnings("NullAway") public void startCallableProcessing(final WebAsyncTask webAsyncTask, Object... processingContext) throws Exception { @@ -349,6 +351,10 @@ public void startCallableProcessing(final WebAsyncTask webAsyncTask, Object.. if (logger.isDebugEnabled()) { logger.debug("Servlet container error notification for " + formatUri(this.asyncWebRequest) + ": " + ex); } + if (DisconnectedClientHelper.isClientDisconnectedException(ex)) { + ex = new AsyncRequestNotUsableException( + "Servlet container error notification for disconnected client", ex); + } Object result = interceptorChain.triggerAfterError(this.asyncWebRequest, callable, ex); result = (result != CallableProcessingInterceptor.RESULT_NONE ? result : ex); setConcurrentResultAndDispatch(result); @@ -382,36 +388,6 @@ public void startCallableProcessing(final WebAsyncTask webAsyncTask, Object.. } } - private void setConcurrentResultAndDispatch(@Nullable Object result) { - Assert.state(this.asyncWebRequest != null, "AsyncWebRequest must not be null"); - synchronized (WebAsyncManager.this) { - if (!this.state.compareAndSet(State.ASYNC_PROCESSING, State.RESULT_SET)) { - if (logger.isDebugEnabled()) { - logger.debug("Async result already set: [" + this.state.get() + - "], ignored result for " + formatUri(this.asyncWebRequest)); - } - return; - } - - this.concurrentResult = result; - if (logger.isDebugEnabled()) { - logger.debug("Async result set for " + formatUri(this.asyncWebRequest)); - } - - if (this.asyncWebRequest.isAsyncComplete()) { - if (logger.isDebugEnabled()) { - logger.debug("Async request already completed for " + formatUri(this.asyncWebRequest)); - } - return; - } - - if (logger.isDebugEnabled()) { - logger.debug("Performing async dispatch for " + formatUri(this.asyncWebRequest)); - } - this.asyncWebRequest.dispatch(); - } - } - /** * Start concurrent request processing and initialize the given * {@link DeferredResult} with a {@link DeferredResultHandler} that saves @@ -426,6 +402,7 @@ private void setConcurrentResultAndDispatch(@Nullable Object result) { * @see #getConcurrentResult() * @see #getConcurrentResultContext() */ + @SuppressWarnings("NullAway") public void startDeferredResultProcessing( final DeferredResult deferredResult, Object... processingContext) throws Exception { @@ -443,7 +420,7 @@ public void startDeferredResultProcessing( } List interceptors = new ArrayList<>(); - interceptors.add(deferredResult.getInterceptor()); + interceptors.add(deferredResult.getLifecycleInterceptor()); interceptors.addAll(this.deferredResultInterceptors.values()); interceptors.add(timeoutDeferredResultInterceptor); @@ -455,6 +432,11 @@ public void startDeferredResultProcessing( } try { interceptorChain.triggerAfterTimeout(this.asyncWebRequest, deferredResult); + synchronized (WebAsyncManager.this) { + // If application thread set the DeferredResult first in a race, + // we must still not return until setConcurrentResultAndDispatch is done + return; + } } catch (Throwable ex) { setConcurrentResultAndDispatch(ex); @@ -465,11 +447,17 @@ public void startDeferredResultProcessing( if (logger.isDebugEnabled()) { logger.debug("Servlet container error notification for " + formatUri(this.asyncWebRequest)); } + if (DisconnectedClientHelper.isClientDisconnectedException(ex)) { + ex = new AsyncRequestNotUsableException( + "Servlet container error notification for disconnected client", ex); + } try { - if (!interceptorChain.triggerAfterError(this.asyncWebRequest, deferredResult, ex)) { + interceptorChain.triggerAfterError(this.asyncWebRequest, deferredResult, ex); + synchronized (WebAsyncManager.this) { + // If application thread set the DeferredResult first in a race, + // we must still not return until setConcurrentResultAndDispatch is done return; } - deferredResult.setErrorResult(ex); } catch (Throwable interceptorEx) { setConcurrentResultAndDispatch(interceptorEx); @@ -508,6 +496,36 @@ private void startAsyncProcessing(Object[] processingContext) { this.asyncWebRequest.startAsync(); } + private void setConcurrentResultAndDispatch(@Nullable Object result) { + Assert.state(this.asyncWebRequest != null, "AsyncWebRequest must not be null"); + synchronized (WebAsyncManager.this) { + if (!this.state.compareAndSet(State.ASYNC_PROCESSING, State.RESULT_SET)) { + if (logger.isDebugEnabled()) { + logger.debug("Async result already set: [" + this.state.get() + "], " + + "ignored result for " + formatUri(this.asyncWebRequest)); + } + return; + } + + this.concurrentResult = result; + if (logger.isDebugEnabled()) { + logger.debug("Async result set for " + formatUri(this.asyncWebRequest)); + } + + if (this.asyncWebRequest.isAsyncComplete()) { + if (logger.isDebugEnabled()) { + logger.debug("Async request already completed for " + formatUri(this.asyncWebRequest)); + } + return; + } + + if (logger.isDebugEnabled()) { + logger.debug("Performing async dispatch for " + formatUri(this.asyncWebRequest)); + } + this.asyncWebRequest.dispatch(); + } + } + private static String formatUri(AsyncWebRequest asyncWebRequest) { HttpServletRequest request = asyncWebRequest.getNativeRequest(HttpServletRequest.class); return (request != null ? "\"" + request.getRequestURI() + "\"" : "servlet container"); @@ -517,13 +535,13 @@ private static String formatUri(AsyncWebRequest asyncWebRequest) { /** * Represents a state for {@link WebAsyncManager} to be in. *

        -	 *        NOT_STARTED <------+
        -	 *             |             |
        -	 *             v             |
        -	 *      ASYNC_PROCESSING     |
        -	 *             |             |
        -	 *             v             |
        -	 *         RESULT_SET -------+
        +	 *     +------> NOT_STARTED <------+
        +	 *     |             |             |
        +	 *     |             v             |
        +	 *     |      ASYNC_PROCESSING     |
        +	 *     |             |             |
        +	 *     |             v             |
        +	 *     <-------+ RESULT_SET -------+
         	 * 
        * @since 5.3.33 */ diff --git a/spring-web/src/main/java/org/springframework/web/context/support/AbstractRefreshableWebApplicationContext.java b/spring-web/src/main/java/org/springframework/web/context/support/AbstractRefreshableWebApplicationContext.java index 0c3d92fb7dd9..b679c2185420 100644 --- a/spring-web/src/main/java/org/springframework/web/context/support/AbstractRefreshableWebApplicationContext.java +++ b/spring-web/src/main/java/org/springframework/web/context/support/AbstractRefreshableWebApplicationContext.java @@ -49,7 +49,7 @@ * by the {@link #getConfigLocations} method. * *

        Interprets resource paths as servlet context resources, i.e. as paths beneath - * the web application root. Absolute paths, e.g. for files outside the web app root, + * the web application root. Absolute paths, for example, for files outside the web app root, * can be accessed via "file:" URLs, as implemented by * {@link org.springframework.core.io.DefaultResourceLoader}. * diff --git a/spring-web/src/main/java/org/springframework/web/context/support/AnnotationConfigWebApplicationContext.java b/spring-web/src/main/java/org/springframework/web/context/support/AnnotationConfigWebApplicationContext.java index de5cbf45d8ba..8c673266578e 100644 --- a/spring-web/src/main/java/org/springframework/web/context/support/AnnotationConfigWebApplicationContext.java +++ b/spring-web/src/main/java/org/springframework/web/context/support/AnnotationConfigWebApplicationContext.java @@ -156,7 +156,7 @@ protected ScopeMetadataResolver getScopeMetadataResolver() { *

        Note that {@link #refresh()} must be called in order for the context * to fully process the new classes. * @param componentClasses one or more component classes, - * e.g. {@link org.springframework.context.annotation.Configuration @Configuration} classes + * for example, {@link org.springframework.context.annotation.Configuration @Configuration} classes * @see #scan(String...) * @see #loadBeanDefinitions(DefaultListableBeanFactory) * @see #setConfigLocation(String) diff --git a/spring-web/src/main/java/org/springframework/web/context/support/GenericWebApplicationContext.java b/spring-web/src/main/java/org/springframework/web/context/support/GenericWebApplicationContext.java index 577fd7d482c4..1c6a3dea00a7 100644 --- a/spring-web/src/main/java/org/springframework/web/context/support/GenericWebApplicationContext.java +++ b/spring-web/src/main/java/org/springframework/web/context/support/GenericWebApplicationContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -96,7 +96,6 @@ public class GenericWebApplicationContext extends GenericApplicationContext * @see #refresh */ public GenericWebApplicationContext() { - super(); } /** diff --git a/spring-web/src/main/java/org/springframework/web/context/support/ServletContextResource.java b/spring-web/src/main/java/org/springframework/web/context/support/ServletContextResource.java index cb8e8ffb8943..6bdaa14a9dfe 100644 --- a/spring-web/src/main/java/org/springframework/web/context/support/ServletContextResource.java +++ b/spring-web/src/main/java/org/springframework/web/context/support/ServletContextResource.java @@ -112,7 +112,7 @@ public boolean exists() { /** * This implementation delegates to {@code ServletContext.getResourceAsStream}, - * which returns {@code null} in case of a non-readable resource (e.g. a directory). + * which returns {@code null} in case of a non-readable resource (for example, a directory). * @see jakarta.servlet.ServletContext#getResourceAsStream(String) */ @Override diff --git a/spring-web/src/main/java/org/springframework/web/context/support/ServletContextResourcePatternResolver.java b/spring-web/src/main/java/org/springframework/web/context/support/ServletContextResourcePatternResolver.java index 386f81fc3611..ae316792fdaa 100644 --- a/spring-web/src/main/java/org/springframework/web/context/support/ServletContextResourcePatternResolver.java +++ b/spring-web/src/main/java/org/springframework/web/context/support/ServletContextResourcePatternResolver.java @@ -104,6 +104,7 @@ protected Set doFindPathMatchingFileResources(Resource rootDirResource * @see ServletContextResource * @see jakarta.servlet.ServletContext#getResourcePaths */ + @SuppressWarnings("NullAway") protected void doRetrieveMatchingServletContextResources( ServletContext servletContext, String fullPattern, String dir, Set result) throws IOException { diff --git a/spring-web/src/main/java/org/springframework/web/context/support/StaticWebApplicationContext.java b/spring-web/src/main/java/org/springframework/web/context/support/StaticWebApplicationContext.java index a03c975d9e15..63b4b5f57ce1 100644 --- a/spring-web/src/main/java/org/springframework/web/context/support/StaticWebApplicationContext.java +++ b/spring-web/src/main/java/org/springframework/web/context/support/StaticWebApplicationContext.java @@ -42,7 +42,7 @@ * despite not actually supporting external configuration files. * *

        Interprets resource paths as servlet context resources, i.e. as paths beneath - * the web application root. Absolute paths, e.g. for files outside the web app root, + * the web application root. Absolute paths, for example, for files outside the web app root, * can be accessed via "file:" URLs, as implemented by * {@link org.springframework.core.io.DefaultResourceLoader}. * diff --git a/spring-web/src/main/java/org/springframework/web/context/support/XmlWebApplicationContext.java b/spring-web/src/main/java/org/springframework/web/context/support/XmlWebApplicationContext.java index 2f396175b21c..a9d6efee1dc2 100644 --- a/spring-web/src/main/java/org/springframework/web/context/support/XmlWebApplicationContext.java +++ b/spring-web/src/main/java/org/springframework/web/context/support/XmlWebApplicationContext.java @@ -97,7 +97,7 @@ protected void loadBeanDefinitions(DefaultListableBeanFactory beanFactory) throw /** * Initialize the bean definition reader used for loading the bean * definitions of this context. Default implementation is empty. - *

        Can be overridden in subclasses, e.g. for turning off XML validation + *

        Can be overridden in subclasses, for example, for turning off XML validation * or using a different XmlBeanDefinitionParser implementation. * @param beanDefinitionReader the bean definition reader used by this context * @see org.springframework.beans.factory.xml.XmlBeanDefinitionReader#setValidationMode diff --git a/spring-web/src/main/java/org/springframework/web/cors/CorsConfiguration.java b/spring-web/src/main/java/org/springframework/web/cors/CorsConfiguration.java index 4e471cfc233d..bef29cf0e2b4 100644 --- a/spring-web/src/main/java/org/springframework/web/cors/CorsConfiguration.java +++ b/spring-web/src/main/java/org/springframework/web/cors/CorsConfiguration.java @@ -19,7 +19,6 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Collections; -import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.function.Consumer; @@ -57,7 +56,7 @@ public class CorsConfiguration { private static final List ALL_LIST = Collections.singletonList(ALL); - private static final OriginPattern ALL_PATTERN = new OriginPattern("*"); + private static final OriginPattern ALL_PATTERN = new OriginPattern(ALL); private static final List ALL_PATTERN_LIST = Collections.singletonList(ALL_PATTERN); @@ -126,10 +125,10 @@ public CorsConfiguration(CorsConfiguration other) { * A list of origins for which cross-origin requests are allowed where each * value may be one of the following: *

          - *
        • a specific domain, e.g. {@code "https://domain1.com"} - *
        • comma-delimited list of specific domains, e.g. + *
        • a specific domain, for example, {@code "https://domain1.com"} + *
        • comma-delimited list of specific domains, for example, * {@code "https://a1.com,https://a2.com"}; this is convenient when a value - * is resolved through a property placeholder, e.g. {@code "${origin}"}; + * is resolved through a property placeholder, for example, {@code "${origin}"}; * note that such placeholders must be resolved externally. *
        • the CORS defined special value {@code "*"} for all origins *
        @@ -142,7 +141,7 @@ public CorsConfiguration(CorsConfiguration other) { * As a consequence, those combinations are rejected in favor of using * {@link #setAllowedOriginPatterns allowedOriginPatterns} instead. *

        By default this is not set which means that no origins are allowed. - * However, an instance of this class is often initialized further, e.g. for + * However, an instance of this class is often initialized further, for example, for * {@code @CrossOrigin}, via {@link #applyPermitDefaultValues()}. */ public void setAllowedOrigins(@Nullable List origins) { @@ -172,6 +171,7 @@ public List getAllowedOrigins() { /** * Variant of {@link #setAllowedOrigins} for adding one origin at a time. */ + @SuppressWarnings("NullAway") public void addAllowedOrigin(@Nullable String origin) { if (origin == null) { return; @@ -198,9 +198,9 @@ else if (this.allowedOrigins == DEFAULT_PERMIT_ALL && CollectionUtils.isEmpty(th * domain1.com on port 8080 or port 8081 *

      • {@literal https://*.domain1.com:[*]} -- domains ending with * domain1.com on any port, including the default port - *
      • comma-delimited list of patters, e.g. + *
      • comma-delimited list of patters, for example, * {@code "https://*.a1.com,https://*.a2.com"}; this is convenient when a - * value is resolved through a property placeholder, e.g. {@code "${origin}"}; + * value is resolved through a property placeholder, for example, {@code "${origin}"}; * note that such placeholders must be resolved externally. * *

        In contrast to {@link #setAllowedOrigins(List) allowedOrigins} which @@ -245,6 +245,7 @@ public List getAllowedOriginPatterns() { * Variant of {@link #setAllowedOriginPatterns} for adding one origin at a time. * @since 5.3 */ + @SuppressWarnings("NullAway") public void addAllowedOriginPattern(@Nullable String originPattern) { if (originPattern == null) { return; @@ -288,7 +289,7 @@ private static void parseCommaDelimitedOrigin(String rawValue, Consumer } /** - * Set the HTTP methods to allow, e.g. {@code "GET"}, {@code "POST"}, + * Set the HTTP methods to allow, for example, {@code "GET"}, {@code "POST"}, * {@code "PUT"}, etc. The special value {@code "*"} allows all methods. *

        {@code Access-Control-Allow-Methods} response header is set either * to the configured method or to {@code "*"}. Keep in mind however that the @@ -659,7 +660,7 @@ private List combine(@Nullable List source, @Nullable List combined = new LinkedHashSet<>(source.size() + other.size()); + Set combined = CollectionUtils.newLinkedHashSet(source.size() + other.size()); combined.addAll(source); combined.addAll(other); return new ArrayList<>(combined); @@ -677,7 +678,7 @@ private List combinePatterns( if (source.contains(ALL_PATTERN) || other.contains(ALL_PATTERN)) { return ALL_PATTERN_LIST; } - Set combined = new LinkedHashSet<>(source.size() + other.size()); + Set combined = CollectionUtils.newLinkedHashSet(source.size() + other.size()); combined.addAll(source); combined.addAll(other); return new ArrayList<>(combined); @@ -782,7 +783,7 @@ public List checkHeaders(@Nullable List requestHeaders) { /** - * Contains both the user-declared pattern (e.g. "https://*.domain.com") and + * Contains both the user-declared pattern (for example, "https://*.domain.com") and * the regex {@link Pattern} derived from it. */ private static class OriginPattern { diff --git a/spring-web/src/main/java/org/springframework/web/cors/CorsUtils.java b/spring-web/src/main/java/org/springframework/web/cors/CorsUtils.java index 15760cebb651..842c03082153 100644 --- a/spring-web/src/main/java/org/springframework/web/cors/CorsUtils.java +++ b/spring-web/src/main/java/org/springframework/web/cors/CorsUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,7 +43,7 @@ public static boolean isCorsRequest(HttpServletRequest request) { if (origin == null) { return false; } - UriComponents originUrl = UriComponentsBuilder.fromOriginHeader(origin).build(); + UriComponents originUrl = UriComponentsBuilder.fromUriString(origin).build(); String scheme = request.getScheme(); String host = request.getServerName(); int port = request.getServerPort(); diff --git a/spring-web/src/main/java/org/springframework/web/cors/DefaultCorsProcessor.java b/spring-web/src/main/java/org/springframework/web/cors/DefaultCorsProcessor.java index 453cd23d9e8b..158871a196d2 100644 --- a/spring-web/src/main/java/org/springframework/web/cors/DefaultCorsProcessor.java +++ b/spring-web/src/main/java/org/springframework/web/cors/DefaultCorsProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-web/src/main/java/org/springframework/web/cors/PreFlightRequestHandler.java b/spring-web/src/main/java/org/springframework/web/cors/PreFlightRequestHandler.java new file mode 100644 index 000000000000..6988de09f97f --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/cors/PreFlightRequestHandler.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.cors; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Handler for CORS pre-flight requests. + * + * @author Rossen Stoyanchev + * @since 6.2 + */ +public interface PreFlightRequestHandler { + + /** + * Handle a pre-flight request by finding and applying the CORS configuration + * that matches the expected actual request. As a result of handling, the + * response should be updated with CORS headers or rejected with + * {@link org.springframework.http.HttpStatus#FORBIDDEN}. + * @param request current HTTP request + * @param response current HTTP response + */ + void handlePreFlight(HttpServletRequest request, HttpServletResponse response) throws Exception; + +} diff --git a/spring-web/src/main/java/org/springframework/web/cors/reactive/CorsUtils.java b/spring-web/src/main/java/org/springframework/web/cors/reactive/CorsUtils.java index deeacccb5cc6..c5256f24d6fa 100644 --- a/spring-web/src/main/java/org/springframework/web/cors/reactive/CorsUtils.java +++ b/spring-web/src/main/java/org/springframework/web/cors/reactive/CorsUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 static boolean isSameOrigin(ServerHttpRequest request) { Assert.notNull(actualHost, "Actual request host must not be null"); Assert.isTrue(actualPort != -1, "Actual request port must not be undefined"); - UriComponents originUrl = UriComponentsBuilder.fromOriginHeader(origin).build(); + UriComponents originUrl = UriComponentsBuilder.fromUriString(origin).build(); return (actualScheme.equals(originUrl.getScheme()) && actualHost.equals(originUrl.getHost()) && actualPort == getPort(originUrl.getScheme(), originUrl.getPort())); diff --git a/spring-web/src/main/java/org/springframework/web/cors/reactive/DefaultCorsProcessor.java b/spring-web/src/main/java/org/springframework/web/cors/reactive/DefaultCorsProcessor.java index 7a4782be20fa..e1d2b82a6a50 100644 --- a/spring-web/src/main/java/org/springframework/web/cors/reactive/DefaultCorsProcessor.java +++ b/spring-web/src/main/java/org/springframework/web/cors/reactive/DefaultCorsProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-web/src/main/java/org/springframework/web/filter/CorsFilter.java b/spring-web/src/main/java/org/springframework/web/filter/CorsFilter.java index 725dfebddbbf..d604ecc59e9d 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/CorsFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/CorsFilter.java @@ -34,7 +34,7 @@ /** * {@link jakarta.servlet.Filter} to handle CORS pre-flight requests and intercept * CORS simple and actual requests with a {@link CorsProcessor}, and to update - * the response, e.g. with CORS response headers, based on the policy matched + * the response, for example, with CORS response headers, based on the policy matched * through the provided {@link CorsConfigurationSource}. * *

        This is an alternative to configuring CORS in the Spring MVC Java config diff --git a/spring-web/src/main/java/org/springframework/web/filter/ForwardedHeaderFilter.java b/spring-web/src/main/java/org/springframework/web/filter/ForwardedHeaderFilter.java index 4e898b1a5323..0c2dc2a0f1d6 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/ForwardedHeaderFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/ForwardedHeaderFilter.java @@ -353,7 +353,7 @@ public Object getAttribute(String name) { /** * Responsible for the contextPath, requestURI, and requestURL with forwarded * headers in mind, and also taking into account changes to the path of the - * underlying delegate request (e.g. on a Servlet FORWARD). + * underlying delegate request (for example, on a Servlet FORWARD). */ private static class ForwardedPrefixExtractor { @@ -375,7 +375,7 @@ private static class ForwardedPrefixExtractor { * Constructor with required information. * @param delegate supplier for the current * {@link HttpServletRequestWrapper#getRequest() delegate request} which - * may change during a forward (e.g. Tomcat. + * may change during a forward (for example, Tomcat. * @param baseUrl the host, scheme, and port based on forwarded headers */ public ForwardedPrefixExtractor(Supplier delegate, String baseUrl) { @@ -446,7 +446,7 @@ public StringBuffer getRequestUrl() { } private void recalculatePathsIfNecessary() { - // Path of delegate request changed, e.g. FORWARD on Tomcat + // Path of delegate request changed, for example, FORWARD on Tomcat if (!this.actualRequestUri.equals(this.delegate.get().getRequestURI())) { this.actualRequestUri = this.delegate.get().getRequestURI(); // Keep call order 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 03b997852e9b..c7b673293903 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 @@ -188,7 +188,7 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce /** * The dispatcher type {@code jakarta.servlet.DispatcherType.ASYNC} means a * filter can be invoked in more than one thread over the course of a single - * request. Some filters only need to filter the initial thread (e.g. request + * request. Some filters only need to filter the initial thread (for example, request * wrapping) while others may need to be invoked at least once in each * additional thread for example for setting up thread locals or to perform * final processing at the very end. @@ -232,7 +232,7 @@ protected abstract void doFilterInternal( /** * Typically an ERROR dispatch happens after the REQUEST dispatch completes, * and the filter chain starts anew. On some servers however the ERROR - * dispatch may be nested within the REQUEST dispatch, e.g. as a result of + * dispatch may be nested within the REQUEST dispatch, for example, as a result of * calling {@code sendError} on the response. In that case we are still in * the filter chain, on the same thread, but the request and response have * been switched to the original, unwrapped ones. diff --git a/spring-web/src/main/java/org/springframework/web/filter/RequestContextFilter.java b/spring-web/src/main/java/org/springframework/web/filter/RequestContextFilter.java index 3a57d72e9172..a0a214d2fec4 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/RequestContextFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/RequestContextFilter.java @@ -36,7 +36,7 @@ * and Spring's {@link org.springframework.web.servlet.DispatcherServlet} also expose * the same request context to the current thread. * - *

        This filter is mainly for use with third-party servlets, e.g. the JSF FacesServlet. + *

        This filter is mainly for use with third-party servlets, for example, the JSF FacesServlet. * Within Spring's own web support, DispatcherServlet's processing is perfectly sufficient. * * @author Juergen Hoeller @@ -62,7 +62,7 @@ public class RequestContextFilter extends OncePerRequestFilter { * (that is, ending after their initial task, without reuse of the thread). *

        WARNING: Do not use inheritance for child threads if you are * accessing a thread pool which is configured to potentially add new threads - * on demand (e.g. a JDK {@link java.util.concurrent.ThreadPoolExecutor}), + * on demand (for example, a JDK {@link java.util.concurrent.ThreadPoolExecutor}), * since this will expose the inherited context to such a pooled thread. */ public void setThreadContextInheritable(boolean threadContextInheritable) { diff --git a/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java b/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java index 3c4222e9867a..c28e761592da 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java @@ -110,6 +110,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse Observation observation = createOrFetchObservation(request, response); try (Observation.Scope scope = observation.openScope()) { + onScopeOpened(scope, request, response); filterChain.doFilter(request, response); } catch (Exception ex) { @@ -134,6 +135,17 @@ else if (!isAsyncDispatch(request)) { } } + /** + * Notify this filter that a new {@link Observation.Scope} is opened for the + * observation that was just created. + * @param scope the newly opened observation scope + * @param request the HTTP client request + * @param response the filter's response + * @since 6.2 + */ + protected void onScopeOpened(Observation.Scope scope, HttpServletRequest request, HttpServletResponse response) { + } + private Observation createOrFetchObservation(HttpServletRequest request, HttpServletResponse response) { Observation observation = (Observation) request.getAttribute(CURRENT_OBSERVATION_ATTRIBUTE); if (observation == null) { diff --git a/spring-web/src/main/java/org/springframework/web/filter/ShallowEtagHeaderFilter.java b/spring-web/src/main/java/org/springframework/web/filter/ShallowEtagHeaderFilter.java index 7f830f9bdb4a..3564cc8629ca 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/ShallowEtagHeaderFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/ShallowEtagHeaderFilter.java @@ -43,7 +43,7 @@ * not sent, but rather a {@code 304 "Not Modified"} status instead. * *

        Since the ETag is based on the response content, the response - * (e.g. a {@link org.springframework.web.servlet.View}) is still rendered. + * (for example, a {@link org.springframework.web.servlet.View}) is still rendered. * As such, this filter only saves bandwidth, not server performance. * *

        State-changing HTTP methods and other HTTP conditional request headers such as diff --git a/spring-web/src/main/java/org/springframework/web/filter/UrlHandlerFilter.java b/spring-web/src/main/java/org/springframework/web/filter/UrlHandlerFilter.java new file mode 100644 index 000000000000..9566a4faffc5 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/filter/UrlHandlerFilter.java @@ -0,0 +1,401 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.filter; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.PathContainer; +import org.springframework.http.server.RequestPath; +import org.springframework.lang.Nullable; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.util.ServletRequestPathUtils; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * {@link jakarta.servlet.Filter} that modifies the URL, and then redirects or + * wraps the request to apply the change. + * + *

        To create an instance, you can use the following: + * + *

        + * UrlHandlerFilter filter = UrlHandlerFilter
        + *    .trailingSlashHandler("/path1/**").redirect(HttpStatus.PERMANENT_REDIRECT)
        + *    .trailingSlashHandler("/path2/**").wrapRequest()
        + *    .build();
        + * 
        + * + *

        This {@code Filter} should be ordered after {@link ForwardedHeaderFilter} + * and before any security filters. + * + * @author Rossen Stoyanchev + * @since 6.2 + */ +public final class UrlHandlerFilter extends OncePerRequestFilter { + + private static final Log logger = LogFactory.getLog(UrlHandlerFilter.class); + + + private final MultiValueMap handlers; + + + private UrlHandlerFilter(MultiValueMap handlers) { + this.handlers = new LinkedMultiValueMap<>(handlers); + } + + + @Override + protected boolean shouldNotFilterAsyncDispatch() { + return false; + } + + @Override + protected boolean shouldNotFilterErrorDispatch() { + return false; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + RequestPath previousPath = (RequestPath) request.getAttribute(ServletRequestPathUtils.PATH_ATTRIBUTE); + RequestPath path = previousPath; + try { + if (path == null) { + path = ServletRequestPathUtils.parseAndCache(request); + } + for (Map.Entry> entry : this.handlers.entrySet()) { + if (!entry.getKey().supports(request, path)) { + continue; + } + for (PathPattern pattern : entry.getValue()) { + if (pattern.matches(path)) { + entry.getKey().handle(request, response, chain); + return; + } + } + } + } + finally { + if (previousPath != null) { + ServletRequestPathUtils.setParsedRequestPath(previousPath, request); + } + } + + chain.doFilter(request, response); + } + + + /** + * Create a builder by adding a handler for URL's with a trailing slash. + * @param pathPatterns path patterns to map the handler to, for example, + * "/path/*", "/path/**", + * "/path/foo/". + * @return a spec to configure the trailing slash handler with + * @see Builder#trailingSlashHandler(String...) + */ + public static Builder.TrailingSlashSpec trailingSlashHandler(String... pathPatterns) { + return new DefaultBuilder().trailingSlashHandler(pathPatterns); + } + + + /** + * Builder for {@link UrlHandlerFilter}. + */ + public interface Builder { + + /** + * Add a handler for URL's with a trailing slash. + * @param pathPatterns path patterns to map the handler to, for example, + * "/path/*", "/path/**", + * "/path/foo/". + * @return a spec to configure the handler with + */ + TrailingSlashSpec trailingSlashHandler(String... pathPatterns); + + /** + * Build the {@link UrlHandlerFilter} instance. + */ + UrlHandlerFilter build(); + + + /** + * A spec to configure a trailing slash handler. + */ + interface TrailingSlashSpec { + + /** + * Configure a request consumer to be called just before the handler + * is invoked when a URL with a trailing slash is matched. + */ + TrailingSlashSpec intercept(Consumer consumer); + + /** + * Handle requests by sending a redirect to the same URL but the + * trailing slash trimmed. + * @param status the redirect status to use + * @return the top level {@link Builder}, which allows adding more + * handlers and then building the Filter instance. + */ + Builder redirect(HttpStatus status); + + /** + * Handle the request by wrapping it in order to trim the trailing + * slash, and delegating to the rest of the filter chain. + * @return the top level {@link Builder}, which allows adding more + * handlers and then building the Filter instance. + */ + Builder wrapRequest(); + } + } + + + /** + * Default {@link Builder} implementation. + */ + private static final class DefaultBuilder implements Builder { + + private final PathPatternParser patternParser = new PathPatternParser(); + + private final MultiValueMap handlers = new LinkedMultiValueMap<>(); + + @Override + public TrailingSlashSpec trailingSlashHandler(String... patterns) { + return new DefaultTrailingSlashSpec(patterns); + } + + private DefaultBuilder addHandler(List pathPatterns, Handler handler) { + pathPatterns.forEach(pattern -> this.handlers.add(handler, pattern)); + return this; + } + + @Override + public UrlHandlerFilter build() { + return new UrlHandlerFilter(this.handlers); + } + + private final class DefaultTrailingSlashSpec implements TrailingSlashSpec { + + private final List pathPatterns; + + @Nullable + private Consumer interceptor; + + private DefaultTrailingSlashSpec(String[] patterns) { + this.pathPatterns = Arrays.stream(patterns) + .map(pattern -> pattern.endsWith("**") || pattern.endsWith("/") ? pattern : pattern + "/") + .map(patternParser::parse) + .toList(); + } + + @Override + public TrailingSlashSpec intercept(Consumer consumer) { + this.interceptor = (this.interceptor != null ? this.interceptor.andThen(consumer) : consumer); + return this; + } + + @Override + public Builder redirect(HttpStatus status) { + Handler handler = new RedirectTrailingSlashHandler(status, this.interceptor); + return DefaultBuilder.this.addHandler(this.pathPatterns, handler); + } + + @Override + public Builder wrapRequest() { + Handler handler = new RequestWrappingTrailingSlashHandler(this.interceptor); + return DefaultBuilder.this.addHandler(this.pathPatterns, handler); + } + } + } + + + + /** + * Internal handler to encapsulate different ways to handle a request. + */ + private interface Handler { + + /** + * Whether the handler handles the given request. + */ + boolean supports(HttpServletRequest request, RequestPath path); + + /** + * Handle the request, possibly delegating to the rest of the filter chain. + */ + void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException; + } + + + /** + * Base class for trailing slash {@link Handler} implementations. + */ + private abstract static class AbstractTrailingSlashHandler implements Handler { + + private static final Consumer defaultInterceptor = request -> { + if (logger.isTraceEnabled()) { + logger.trace("Handling trailing slash URL: " + + request.getMethod() + " " + request.getRequestURI()); + } + }; + + private final Consumer interceptor; + + protected AbstractTrailingSlashHandler(@Nullable Consumer interceptor) { + this.interceptor = (interceptor != null ? interceptor : defaultInterceptor); + } + + @Override + public boolean supports(HttpServletRequest request, RequestPath path) { + List elements = path.pathWithinApplication().elements(); + return (elements.size() > 1 && elements.get(elements.size() - 1).value().equals("/")); + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + this.interceptor.accept(request); + handleInternal(request, response, chain); + } + + protected abstract void handleInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException; + + protected String trimTrailingSlash(String path) { + int index = (StringUtils.hasLength(path) ? path.lastIndexOf('/') : -1); + return (index != -1 ? path.substring(0, index) : path); + } + } + + + /** + * Path handler that sends a redirect. + */ + private static final class RedirectTrailingSlashHandler extends AbstractTrailingSlashHandler { + + private final HttpStatus httpStatus; + + RedirectTrailingSlashHandler(HttpStatus httpStatus, @Nullable Consumer interceptor) { + super(interceptor); + this.httpStatus = httpStatus; + } + + @Override + public void handleInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException { + + response.resetBuffer(); + response.setStatus(this.httpStatus.value()); + response.setHeader(HttpHeaders.LOCATION, trimTrailingSlash(request.getRequestURI())); + response.flushBuffer(); + } + } + + + /** + * Path handler that wraps the request and continues processing. + */ + private static final class RequestWrappingTrailingSlashHandler extends AbstractTrailingSlashHandler { + + RequestWrappingTrailingSlashHandler(@Nullable Consumer interceptor) { + super(interceptor); + } + + @Override + public void handleInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + String servletPath = request.getServletPath(); + String pathInfo = request.getPathInfo(); + boolean hasPathInfo = StringUtils.hasText(pathInfo); + + request = new TrailingSlashHttpServletRequest( + request, + trimTrailingSlash(request.getRequestURI()), + trimTrailingSlash(request.getRequestURL().toString()), + hasPathInfo ? servletPath : trimTrailingSlash(servletPath), + hasPathInfo ? trimTrailingSlash(pathInfo) : pathInfo); + + chain.doFilter(request, response); + } + } + + + /** + * Wraps the request to return modified path information. + */ + private static class TrailingSlashHttpServletRequest extends HttpServletRequestWrapper { + + private final String requestURI; + + private final StringBuffer requestURL; + + private final String servletPath; + + private final String pathInfo; + + TrailingSlashHttpServletRequest(HttpServletRequest request, + String requestURI, String requestURL, String servletPath, String pathInfo) { + + super(request); + this.requestURI = requestURI; + this.requestURL = new StringBuffer(requestURL); + this.servletPath = servletPath; + this.pathInfo = pathInfo; + } + + @Override + public String getRequestURI() { + return this.requestURI; + } + + @Override + public StringBuffer getRequestURL() { + return this.requestURL; + } + + @Override + public String getServletPath() { + return this.servletPath; + } + + @Override + public String getPathInfo() { + return this.pathInfo; + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/filter/reactive/ServerWebExchangeContextFilter.java b/spring-web/src/main/java/org/springframework/web/filter/reactive/ServerWebExchangeContextFilter.java index 7ff17b72467a..5364594447d2 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/reactive/ServerWebExchangeContextFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/reactive/ServerWebExchangeContextFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,19 +64,4 @@ public static Optional getExchange(ContextView contextView) { return contextView.getOrEmpty(EXCHANGE_CONTEXT_ATTRIBUTE); } - /** - * Access the {@link ServerWebExchange} from a Reactor {@link Context}, - * if available, which is generally the case when - * {@link ServerWebExchangeContextFilter} is present in the filter chain. - * @param context the context to get the exchange from - * @return an {@link Optional} with the exchange if found - * @deprecated in favor of using {@link #getExchange(ContextView)} which - * accepts a {@link ContextView} instead of {@link Context}, reflecting the - * fact that the {@code ContextView} is needed only for reading. - */ - @Deprecated(since = "6.0.6", forRemoval = true) - public static Optional get(Context context) { - return context.getOrEmpty(EXCHANGE_CONTEXT_ATTRIBUTE); - } - } diff --git a/spring-web/src/main/java/org/springframework/web/filter/reactive/UrlHandlerFilter.java b/spring-web/src/main/java/org/springframework/web/filter/reactive/UrlHandlerFilter.java new file mode 100644 index 000000000000..479e9ce4d63c --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/filter/reactive/UrlHandlerFilter.java @@ -0,0 +1,319 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.filter.reactive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.server.PathContainer; +import org.springframework.http.server.RequestPath; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.lang.Nullable; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * {@link org.springframework.web.server.WebFilter} that modifies the URL, and + * then redirects or wraps the request to apply the change. + * + *

        To create an instance, you can use the following: + * + *

        + * UrlHandlerFilter filter = UrlHandlerFilter
        + *    .trailingSlashHandler("/path1/**").redirect(HttpStatus.PERMANENT_REDIRECT)
        + *    .trailingSlashHandler("/path2/**").mutateRequest()
        + *    .build();
        + * 
        + * + *

        This {@code WebFilter} should be ordered ahead of security filters. + * + * @author Rossen Stoyanchev + * @since 6.2 + */ +public final class UrlHandlerFilter implements WebFilter { + + private static final Log logger = LogFactory.getLog(UrlHandlerFilter.class); + + + private final MultiValueMap handlers; + + + private UrlHandlerFilter(MultiValueMap handlers) { + this.handlers = new LinkedMultiValueMap<>(handlers); + } + + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + RequestPath path = exchange.getRequest().getPath(); + for (Map.Entry> entry : this.handlers.entrySet()) { + if (!entry.getKey().supports(exchange)) { + continue; + } + for (PathPattern pattern : entry.getValue()) { + if (pattern.matches(path)) { + return entry.getKey().handle(exchange, chain); + } + } + } + return chain.filter(exchange); + } + + /** + * Create a builder by adding a handler for URL's with a trailing slash. + * @param pathPatterns path patterns to map the handler to, e.g. + * "/path/*", "/path/**", + * "/path/foo/". + * @return a spec to configure the trailing slash handler with + * @see Builder#trailingSlashHandler(String...) + */ + public static Builder.TrailingSlashSpec trailingSlashHandler(String... pathPatterns) { + return new DefaultBuilder().trailingSlashHandler(pathPatterns); + } + + + /** + * Builder for {@link UrlHandlerFilter}. + */ + public interface Builder { + + /** + * Add a handler for URL's with a trailing slash. + * @param pathPatterns path patterns to map the handler to, e.g. + * "/path/*", "/path/**", + * "/path/foo/". + * @return a spec to configure the handler with + */ + TrailingSlashSpec trailingSlashHandler(String... pathPatterns); + + /** + * Build the {@link UrlHandlerFilter} instance. + */ + UrlHandlerFilter build(); + + + /** + * A spec to configure a trailing slash handler. + */ + interface TrailingSlashSpec { + + /** + * Configure a request interceptor to be called just before the handler + * is invoked when a URL with a trailing slash is matched. + */ + TrailingSlashSpec intercept(Function> interceptor); + + /** + * Handle requests by sending a redirect to the same URL but the + * trailing slash trimmed. + * @param statusCode the redirect status to use + * @return the top level {@link Builder}, which allows adding more + * handlers and then building the Filter instance. + */ + Builder redirect(HttpStatusCode statusCode); + + /** + * Handle the request by wrapping it in order to trim the trailing + * slash, and delegating to the rest of the filter chain. + * @return the top level {@link Builder}, which allows adding more + * handlers and then building the Filter instance. + */ + Builder mutateRequest(); + } + } + + + /** + * Default {@link Builder} implementation. + */ + private static final class DefaultBuilder implements Builder { + + private final PathPatternParser patternParser = new PathPatternParser(); + + private final MultiValueMap handlers = new LinkedMultiValueMap<>(); + + @Override + public TrailingSlashSpec trailingSlashHandler(String... patterns) { + return new DefaultTrailingSlashSpec(patterns); + } + + private DefaultBuilder addHandler(List pathPatterns, Handler handler) { + pathPatterns.forEach(pattern -> this.handlers.add(handler, pattern)); + return this; + } + + @Override + public UrlHandlerFilter build() { + return new UrlHandlerFilter(this.handlers); + } + + + private final class DefaultTrailingSlashSpec implements TrailingSlashSpec { + + private final List pathPatterns; + + @Nullable + private List>> interceptors; + + private DefaultTrailingSlashSpec(String[] patterns) { + this.pathPatterns = Arrays.stream(patterns) + .map(pattern -> pattern.endsWith("**") || pattern.endsWith("/") ? pattern : pattern + "/") + .map(patternParser::parse) + .toList(); + } + + @Override + public TrailingSlashSpec intercept(Function> interceptor) { + this.interceptors = (this.interceptors != null ? this.interceptors : new ArrayList<>()); + this.interceptors.add(interceptor); + return this; + } + + @Override + public Builder redirect(HttpStatusCode statusCode) { + Handler handler = new RedirectTrailingSlashHandler(statusCode, this.interceptors); + return DefaultBuilder.this.addHandler(this.pathPatterns, handler); + } + + @Override + public Builder mutateRequest() { + Handler handler = new RequestWrappingTrailingSlashHandler(this.interceptors); + return DefaultBuilder.this.addHandler(this.pathPatterns, handler); + } + } + } + + + /** + * Internal handler to encapsulate different ways to handle a request. + */ + private interface Handler { + + /** + * Whether the handler handles the given request. + */ + boolean supports(ServerWebExchange exchange); + + /** + * Handle the request, possibly delegating to the rest of the filter chain. + */ + Mono handle(ServerWebExchange exchange, WebFilterChain chain); + } + + + /** + * Base class for trailing slash {@link Handler} implementations. + */ + private abstract static class AbstractTrailingSlashHandler implements Handler { + + private static final List>> defaultInterceptors = + List.of(request -> { + if (logger.isTraceEnabled()) { + logger.trace("Handling trailing slash URL: " + request.getMethod() + " " + request.getURI()); + } + return Mono.empty(); + }); + + private final List>> interceptors; + + protected AbstractTrailingSlashHandler(@Nullable List>> interceptors) { + this.interceptors = (interceptors != null ? new ArrayList<>(interceptors) : defaultInterceptors); + } + + @Override + public boolean supports(ServerWebExchange exchange) { + ServerHttpRequest request = exchange.getRequest(); + List elements = request.getPath().pathWithinApplication().elements(); + return (elements.size() > 1 && elements.get(elements.size() - 1).value().equals("/")); + } + + @Override + public Mono handle(ServerWebExchange exchange, WebFilterChain chain) { + List> monos = new ArrayList<>(this.interceptors.size()); + this.interceptors.forEach(interceptor -> monos.add(interceptor.apply(exchange.getRequest()))); + return Flux.concat(monos).then(Mono.defer(() -> handleInternal(exchange, chain))); + } + + protected abstract Mono handleInternal(ServerWebExchange exchange, WebFilterChain chain); + + protected String trimTrailingSlash(ServerHttpRequest request) { + String path = request.getURI().getRawPath(); + int index = (StringUtils.hasLength(path) ? path.lastIndexOf('/') : -1); + return (index != -1 ? path.substring(0, index) : path); + } + } + + + /** + * Path handler that sends a redirect. + */ + private static final class RedirectTrailingSlashHandler extends AbstractTrailingSlashHandler { + + private final HttpStatusCode statusCode; + + RedirectTrailingSlashHandler( + HttpStatusCode statusCode, @Nullable List>> interceptors) { + + super(interceptors); + this.statusCode = statusCode; + } + + @Override + public Mono handleInternal(ServerWebExchange exchange, WebFilterChain chain) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(this.statusCode); + response.getHeaders().set(HttpHeaders.LOCATION, trimTrailingSlash(exchange.getRequest())); + return Mono.empty(); + } + } + + + /** + * Path handler that mutates the request and continues processing. + */ + private static final class RequestWrappingTrailingSlashHandler extends AbstractTrailingSlashHandler { + + RequestWrappingTrailingSlashHandler(@Nullable List>> interceptors) { + super(interceptors); + } + + @Override + public Mono handleInternal(ServerWebExchange exchange, WebFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + ServerHttpRequest mutatedRequest = request.mutate().path(trimTrailingSlash(request)).build(); + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/method/ControllerAdviceBean.java b/spring-web/src/main/java/org/springframework/web/method/ControllerAdviceBean.java index 969213552c3d..a3b45aecbdb8 100644 --- a/spring-web/src/main/java/org/springframework/web/method/ControllerAdviceBean.java +++ b/spring-web/src/main/java/org/springframework/web/method/ControllerAdviceBean.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +33,6 @@ import org.springframework.context.ConfigurableApplicationContext; import org.springframework.core.OrderComparator; import org.springframework.core.Ordered; -import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.OrderUtils; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -43,10 +42,11 @@ /** * Encapsulates information about an {@link ControllerAdvice @ControllerAdvice} * Spring-managed bean without necessarily requiring it to be instantiated. + * The {@link #findAnnotatedBeans(ApplicationContext)} method can be used to + * discover such beans. * - *

        The {@link #findAnnotatedBeans(ApplicationContext)} method can be used to - * discover such beans. However, a {@code ControllerAdviceBean} may be created - * from any object, including ones without an {@code @ControllerAdvice} annotation. + *

        This class is internal to Spring Framework and is not meant to be used + * by applications to manually create {@code @ControllerAdvice} beans. * * @author Rossen Stoyanchev * @author Brian Clozel @@ -56,11 +56,7 @@ */ public class ControllerAdviceBean implements Ordered { - /** - * Reference to the actual bean instance or a {@code String} representing - * the bean name. - */ - private final Object beanOrName; + private final String beanName; private final boolean isSingleton; @@ -76,38 +72,12 @@ public class ControllerAdviceBean implements Ordered { private final HandlerTypePredicate beanTypePredicate; - @Nullable private final BeanFactory beanFactory; @Nullable private Integer order; - /** - * Create a {@code ControllerAdviceBean} using the given bean instance. - * @param bean the bean instance - */ - public ControllerAdviceBean(Object bean) { - Assert.notNull(bean, "Bean must not be null"); - this.beanOrName = bean; - this.isSingleton = true; - this.resolvedBean = bean; - this.beanType = ClassUtils.getUserClass(bean.getClass()); - this.beanTypePredicate = createBeanTypePredicate(this.beanType); - this.beanFactory = null; - } - - /** - * Create a {@code ControllerAdviceBean} using the given bean name and - * {@code BeanFactory}. - * @param beanName the name of the bean - * @param beanFactory a {@code BeanFactory} to retrieve the bean type initially - * and later to resolve the actual bean - */ - public ControllerAdviceBean(String beanName, BeanFactory beanFactory) { - this(beanName, beanFactory, null); - } - /** * Create a {@code ControllerAdviceBean} using the given bean name, * {@code BeanFactory}, and {@link ControllerAdvice @ControllerAdvice} @@ -115,21 +85,20 @@ public ControllerAdviceBean(String beanName, BeanFactory beanFactory) { * @param beanName the name of the bean * @param beanFactory a {@code BeanFactory} to retrieve the bean type initially * and later to resolve the actual bean - * @param controllerAdvice the {@code @ControllerAdvice} annotation for the - * bean, or {@code null} if not yet retrieved + * @param controllerAdvice the {@code @ControllerAdvice} annotation for the bean * @since 5.2 */ - public ControllerAdviceBean(String beanName, BeanFactory beanFactory, @Nullable ControllerAdvice controllerAdvice) { + public ControllerAdviceBean(String beanName, BeanFactory beanFactory, ControllerAdvice controllerAdvice) { Assert.hasText(beanName, "Bean name must contain text"); Assert.notNull(beanFactory, "BeanFactory must not be null"); Assert.isTrue(beanFactory.containsBean(beanName), () -> "BeanFactory [" + beanFactory + "] does not contain specified controller advice bean '" + beanName + "'"); + Assert.notNull(controllerAdvice, "ControllerAdvice must not be null"); - this.beanOrName = beanName; + this.beanName = beanName; this.isSingleton = beanFactory.isSingleton(beanName); this.beanType = getBeanType(beanName, beanFactory); - this.beanTypePredicate = (controllerAdvice != null ? createBeanTypePredicate(controllerAdvice) : - createBeanTypePredicate(this.beanType)); + this.beanTypePredicate = createBeanTypePredicate(controllerAdvice); this.beanFactory = beanFactory; } @@ -158,21 +127,14 @@ public ControllerAdviceBean(String beanName, BeanFactory beanFactory, @Nullable @Override public int getOrder() { if (this.order == null) { - String beanName = null; Object resolvedBean = null; - if (this.beanFactory != null && this.beanOrName instanceof String stringBeanName) { - beanName = stringBeanName; - String targetBeanName = ScopedProxyUtils.getTargetBeanName(beanName); - boolean isScopedProxy = this.beanFactory.containsBean(targetBeanName); - // Avoid eager @ControllerAdvice bean resolution for scoped proxies, - // since attempting to do so during context initialization would result - // in an exception due to the current absence of the scope. For example, - // an HTTP request or session scope is not active during initialization. - if (!isScopedProxy && !ScopedProxyUtils.isScopedTarget(beanName)) { - resolvedBean = resolveBean(); - } - } - else { + String targetBeanName = ScopedProxyUtils.getTargetBeanName(this.beanName); + boolean isScopedProxy = this.beanFactory.containsBean(targetBeanName); + // Avoid eager @ControllerAdvice bean resolution for scoped proxies, + // since attempting to do so during context initialization would result + // in an exception due to the current absence of the scope. For example, + // an HTTP request or session scope is not active during initialization. + if (!isScopedProxy && !ScopedProxyUtils.isScopedTarget(this.beanName)) { resolvedBean = resolveBean(); } @@ -180,9 +142,9 @@ public int getOrder() { this.order = ordered.getOrder(); } else { - if (beanName != null && this.beanFactory instanceof ConfigurableBeanFactory cbf) { + if (this.beanFactory instanceof ConfigurableBeanFactory cbf) { try { - BeanDefinition bd = cbf.getMergedBeanDefinition(beanName); + BeanDefinition bd = cbf.getMergedBeanDefinition(this.beanName); if (bd instanceof RootBeanDefinition rbd) { Method factoryMethod = rbd.getResolvedFactoryMethod(); if (factoryMethod != null) { @@ -220,16 +182,13 @@ public Class getBeanType() { /** * Get the bean instance for this {@code ControllerAdviceBean}, if necessary * resolving the bean name through the {@link BeanFactory}. - *

        As of Spring Framework 5.2, once the bean instance has been resolved it - * will be cached if it is a singleton, thereby avoiding repeated lookups in - * the {@code BeanFactory}. + *

        Once the bean instance has been resolved it will be cached if it is a + * singleton, thereby avoiding repeated lookups in the {@code BeanFactory}. */ public Object resolveBean() { if (this.resolvedBean == null) { - // this.beanOrName must be a String representing the bean name if - // this.resolvedBean is null. - Object resolvedBean = obtainBeanFactory().getBean((String) this.beanOrName); - // Don't cache non-singletons (e.g., prototypes). + Object resolvedBean = this.beanFactory.getBean(this.beanName); + // Don't cache non-singletons (for example, prototypes). if (!this.isSingleton) { return resolvedBean; } @@ -238,11 +197,6 @@ public Object resolveBean() { return this.resolvedBean; } - private BeanFactory obtainBeanFactory() { - Assert.state(this.beanFactory != null, "No BeanFactory set"); - return this.beanFactory; - } - /** * Check whether the given bean type should be advised by this * {@code ControllerAdviceBean}. @@ -258,17 +212,17 @@ public boolean isApplicableToBeanType(@Nullable Class beanType) { @Override public boolean equals(@Nullable Object other) { return (this == other || (other instanceof ControllerAdviceBean that && - this.beanOrName.equals(that.beanOrName) && this.beanFactory == that.beanFactory)); + this.beanName.equals(that.beanName) && this.beanFactory == that.beanFactory)); } @Override public int hashCode() { - return this.beanOrName.hashCode(); + return this.beanName.hashCode(); } @Override public String toString() { - return this.beanOrName.toString(); + return this.beanName; } @@ -276,8 +230,8 @@ public String toString() { * Find beans annotated with {@link ControllerAdvice @ControllerAdvice} in the * given {@link ApplicationContext} and wrap them as {@code ControllerAdviceBean} * instances. - *

        As of Spring Framework 5.2, the {@code ControllerAdviceBean} instances - * in the returned list are sorted using {@link OrderComparator#sort(List)}. + *

        Note that the {@code ControllerAdviceBean} instances in the returned list + * are sorted using {@link OrderComparator#sort(List)}. * @see #getOrder() * @see OrderComparator * @see Ordered @@ -309,22 +263,13 @@ private static Class getBeanType(String beanName, BeanFactory beanFactory) { return (beanType != null ? ClassUtils.getUserClass(beanType) : null); } - private static HandlerTypePredicate createBeanTypePredicate(@Nullable Class beanType) { - ControllerAdvice controllerAdvice = (beanType != null ? - AnnotatedElementUtils.findMergedAnnotation(beanType, ControllerAdvice.class) : null); - return createBeanTypePredicate(controllerAdvice); - } - - private static HandlerTypePredicate createBeanTypePredicate(@Nullable ControllerAdvice controllerAdvice) { - if (controllerAdvice != null) { - return HandlerTypePredicate.builder() - .basePackage(controllerAdvice.basePackages()) - .basePackageClass(controllerAdvice.basePackageClasses()) - .assignableType(controllerAdvice.assignableTypes()) - .annotation(controllerAdvice.annotations()) - .build(); - } - return HandlerTypePredicate.forAnyHandlerType(); + private static HandlerTypePredicate createBeanTypePredicate(ControllerAdvice controllerAdvice) { + return HandlerTypePredicate.builder() + .basePackage(controllerAdvice.basePackages()) + .basePackageClass(controllerAdvice.basePackageClasses()) + .assignableType(controllerAdvice.assignableTypes()) + .annotation(controllerAdvice.annotations()) + .build(); } } diff --git a/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java b/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java index a7cbd3d878d2..44c7cd2066ea 100644 --- a/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java +++ b/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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 @@ * method annotations, etc. * *

        The class may be created with a bean instance or with a bean name - * (e.g. lazy-init bean, prototype bean). Use {@link #createWithResolvedBean()} + * (for example, lazy-init bean, prototype bean). Use {@link #createWithResolvedBean()} * to obtain a {@code HandlerMethod} instance with a bean instance resolved * through the associated {@link BeanFactory}. * @@ -181,9 +181,13 @@ protected HandlerMethod(HandlerMethod handlerMethod) { } /** - * Re-create HandlerMethod with additional input. + * Re-create new HandlerMethod instance that copies the given HandlerMethod + * but replaces the handler, and optionally checks for the presence of + * validation annotations. + *

        Subclasses can override this to ensure that a HandlerMethod is of the + * same type if re-created. */ - private HandlerMethod(HandlerMethod handlerMethod, @Nullable Object handler, boolean initValidateFlags) { + protected HandlerMethod(HandlerMethod handlerMethod, @Nullable Object handler, boolean initValidateFlags) { super(handlerMethod); this.bean = (handler != null ? handler : handlerMethod.bean); this.beanFactory = handlerMethod.beanFactory; @@ -197,7 +201,8 @@ private HandlerMethod(HandlerMethod handlerMethod, @Nullable Object handler, boo handlerMethod.validateReturnValue); this.responseStatus = handlerMethod.responseStatus; this.responseStatusReason = handlerMethod.responseStatusReason; - this.resolvedFromHandlerMethod = handlerMethod; + this.resolvedFromHandlerMethod = (handlerMethod.resolvedFromHandlerMethod != null ? + handlerMethod.resolvedFromHandlerMethod : handlerMethod); this.description = handlerMethod.toString(); } @@ -317,15 +322,19 @@ public HandlerMethod createWithValidateFlags() { } /** - * If the provided instance contains a bean name rather than an object instance, - * the bean name is resolved before a {@link HandlerMethod} is created and returned. + * If the {@link #getBean() handler} is a bean name rather than the actual + * handler instance, resolve the bean name through Spring configuration + * (e.g. for prototype beans), and return a new {@link HandlerMethod} + * instance with the resolved handler. + *

        If the {@link #getBean() handler} is not String, return the same instance. */ public HandlerMethod createWithResolvedBean() { - Object handler = this.bean; - if (this.bean instanceof String beanName) { - Assert.state(this.beanFactory != null, "Cannot resolve bean name without BeanFactory"); - handler = this.beanFactory.getBean(beanName); + if (!(this.bean instanceof String beanName)) { + return this; } + + Assert.state(this.beanFactory != null, "Cannot resolve bean name without BeanFactory"); + Object handler = this.beanFactory.getBean(beanName); Assert.notNull(handler, "No handler instance"); return new HandlerMethod(this, handler, false); } @@ -342,7 +351,7 @@ public String getShortLogMessage() { @Override public boolean equals(@Nullable Object other) { - return (this == other || (super.equals(other) && this.bean.equals(((HandlerMethod) other).bean))); + return (this == other || (super.equals(other) && other instanceof HandlerMethod otherMethod && this.bean.equals(otherMethod.bean))); } @Override @@ -372,7 +381,7 @@ protected void assertTargetBean(Method method, Object targetBean, Object[] args) String text = "The mapped handler method class '" + methodDeclaringClass.getName() + "' is not an instance of the actual controller bean class '" + targetBeanClass.getName() + "'. If the controller requires proxying " + - "(e.g. due to @Transactional), please use class-based proxying."; + "(for example, due to @Transactional), please use class-based proxying."; throw new IllegalStateException(formatInvokeError(text, args)); } } 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 c0366067f4df..427a886c62d4 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 @@ -110,7 +110,7 @@ public static HandlerTypePredicate forAnyHandlerType() { } /** - * Match handlers declared under a base package, e.g. "org.example". + * Match handlers declared under a base package, for example, "org.example". * @param packages one or more base package names */ public static HandlerTypePredicate forBasePackage(String... packages) { @@ -163,7 +163,7 @@ public static class Builder { private final List> annotations = new ArrayList<>(); /** - * Match handlers declared under a base package, e.g. "org.example". + * Match handlers declared under a base package, for example, "org.example". * @param packages one or more base package classes */ public Builder basePackage(String... packages) { 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 131446e8bf4e..145d108f0ae5 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 @@ -122,10 +122,10 @@ public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAn arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); } else if (namedValueInfo.required && !nestedParameter.isOptional()) { - handleMissingValue(namedValueInfo.name, nestedParameter, webRequest); + handleMissingValue(resolvedName.toString(), nestedParameter, webRequest); } if (!hasDefaultValue) { - arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType()); + arg = handleNullValue(resolvedName.toString(), arg, nestedParameter.getNestedParameterType()); } } else if ("".equals(arg) && namedValueInfo.defaultValue != null) { @@ -141,7 +141,7 @@ else if ("".equals(arg) && namedValueInfo.defaultValue != null) { arg = convertIfNecessary(parameter, webRequest, binderFactory, namedValueInfo, arg); } else if (namedValueInfo.required && !nestedParameter.isOptional()) { - handleMissingValueAfterConversion(namedValueInfo.name, nestedParameter, webRequest); + handleMissingValueAfterConversion(resolvedName.toString(), nestedParameter, webRequest); } } } diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMappingInfo.java b/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMappingInfo.java new file mode 100644 index 000000000000..078ede21d5c1 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMappingInfo.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.method.annotation; + +import java.lang.reflect.Method; +import java.util.Set; + +import org.springframework.http.MediaType; +import org.springframework.util.Assert; + +/** + * {@code @ExceptionHandler} mapping information. It contains: + *

          + *
        • the supported exception types + *
        • the producible media types, if any + *
        • the method in charge of handling the exception + *
        + * @author Brian Clozel + * @since 6.2 + */ +public class ExceptionHandlerMappingInfo { + + private final Set> exceptionTypes; + + private final Set producibleTypes; + + private final Method handlerMethod; + + + ExceptionHandlerMappingInfo(Set> exceptionTypes, Set producibleMediaTypes, Method handlerMethod) { + Assert.notNull(exceptionTypes, "exceptionTypes should not be null"); + Assert.notNull(producibleMediaTypes, "producibleMediaTypes should not be null"); + Assert.notNull(handlerMethod, "handlerMethod should not be null"); + this.exceptionTypes = exceptionTypes; + this.producibleTypes = producibleMediaTypes; + this.handlerMethod = handlerMethod; + } + + + /** + * Return the method responsible for handling the exception. + */ + public Method getHandlerMethod() { + return this.handlerMethod; + } + + /** + * Return the exception types supported by this handler. + */ + public Set> getExceptionTypes() { + return this.exceptionTypes; + } + + /** + * Return the producible media types by this handler. Can be empty. + */ + public Set getProducibleTypes() { + return this.producibleTypes; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolver.java b/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolver.java index 36673d2ece69..80d6d70bdc41 100644 --- a/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolver.java +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author 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,27 +19,42 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.springframework.core.ExceptionDepthComparator; import org.springframework.core.MethodIntrospector; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; import org.springframework.lang.Nullable; import org.springframework.util.Assert; -import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.ConcurrentLruCache; +import org.springframework.util.MimeType; import org.springframework.util.ReflectionUtils.MethodFilter; import org.springframework.web.bind.annotation.ExceptionHandler; /** * Discovers {@linkplain ExceptionHandler @ExceptionHandler} methods in a given class, * including all of its superclasses, and helps to resolve a given {@link Exception} - * to the exception types supported by a given {@link Method}. + * and {@link MediaType} requested by clients to combinations supported by a given {@link Method}. + *

        This resolver will use the exception information declared as {@code @ExceptionHandler} + * annotation attributes, or as a method argument as a fallback. This will throw + * {@code IllegalStateException} instances if: + *

          + *
        • No Exception information could be found for a method + *
        • An invalid {@link MediaType} has been declared as {@code @ExceptionHandler} attribute + *
        • Multiple handlers declare the same exception + media type mapping + *
        * * @author Rossen Stoyanchev * @author Juergen Hoeller * @author Sam Brannen + * @author Brian Clozel * @since 3.1 */ public class ExceptionHandlerMethodResolver { @@ -47,35 +62,42 @@ public class ExceptionHandlerMethodResolver { /** * A filter for selecting {@code @ExceptionHandler} methods. */ - public static final MethodFilter EXCEPTION_HANDLER_METHODS = method -> + private static final MethodFilter EXCEPTION_HANDLER_METHODS = method -> AnnotatedElementUtils.hasAnnotation(method, ExceptionHandler.class); - private static final Method NO_MATCHING_EXCEPTION_HANDLER_METHOD; + private static final ExceptionHandlerMappingInfo NO_MATCHING_EXCEPTION_HANDLER; static { try { - NO_MATCHING_EXCEPTION_HANDLER_METHOD = - ExceptionHandlerMethodResolver.class.getDeclaredMethod("noMatchingExceptionHandler"); + NO_MATCHING_EXCEPTION_HANDLER = new ExceptionHandlerMappingInfo(Set.of(), Set.of(), + ExceptionHandlerMethodResolver.class.getDeclaredMethod("noMatchingExceptionHandler")); } catch (NoSuchMethodException ex) { throw new IllegalStateException("Expected method not found: " + ex); } } + private final Map mappedMethods = new HashMap<>(16); - private final Map, Method> mappedMethods = new HashMap<>(16); - - private final Map, Method> exceptionLookupCache = new ConcurrentReferenceHashMap<>(16); + private final ConcurrentLruCache lookupCache = new ConcurrentLruCache<>(24, + cacheKey -> getMappedMethod(cacheKey.exceptionType(), cacheKey.mediaType())); /** * A constructor that finds {@link ExceptionHandler} methods in the given type. * @param handlerType the type to introspect + * @throws IllegalStateException in case of invalid or ambiguous exception mapping declarations */ public ExceptionHandlerMethodResolver(Class handlerType) { for (Method method : MethodIntrospector.selectMethods(handlerType, EXCEPTION_HANDLER_METHODS)) { - for (Class exceptionType : detectExceptionMappings(method)) { - addExceptionMapping(exceptionType, method); + ExceptionHandlerMappingInfo mappingInfo = detectExceptionMappings(method); + for (Class exceptionType : mappingInfo.getExceptionTypes()) { + for (MediaType producibleType : mappingInfo.getProducibleTypes()) { + addExceptionMapping(new ExceptionMapping(exceptionType, producibleType), mappingInfo); + } + if (mappingInfo.getProducibleTypes().isEmpty()) { + addExceptionMapping(new ExceptionMapping(exceptionType, MediaType.ALL), mappingInfo); + } } } } @@ -86,33 +108,42 @@ public ExceptionHandlerMethodResolver(Class handlerType) { * and then as a fallback from the method signature itself. */ @SuppressWarnings("unchecked") - private List> detectExceptionMappings(Method method) { - List> result = new ArrayList<>(); - detectAnnotationExceptionMappings(method, result); - if (result.isEmpty()) { + private ExceptionHandlerMappingInfo detectExceptionMappings(Method method) { + ExceptionHandler exceptionHandler = readExceptionHandlerAnnotation(method); + List> exceptions = new ArrayList<>(Arrays.asList(exceptionHandler.exception())); + if (exceptions.isEmpty()) { for (Class paramType : method.getParameterTypes()) { if (Throwable.class.isAssignableFrom(paramType)) { - result.add((Class) paramType); + exceptions.add((Class) paramType); } } } - if (result.isEmpty()) { + if (exceptions.isEmpty()) { throw new IllegalStateException("No exception types mapped to " + method); } - return result; + Set mediaTypes = new HashSet<>(); + for (String mediaType : exceptionHandler.produces()) { + try { + mediaTypes.add(MediaType.parseMediaType(mediaType)); + } + catch (InvalidMediaTypeException exc) { + throw new IllegalStateException("Invalid media type [" + mediaType + "] declared on @ExceptionHandler for " + method, exc); + } + } + return new ExceptionHandlerMappingInfo(Set.copyOf(exceptions), mediaTypes, method); } - private void detectAnnotationExceptionMappings(Method method, List> result) { + private ExceptionHandler readExceptionHandlerAnnotation(Method method) { ExceptionHandler ann = AnnotatedElementUtils.findMergedAnnotation(method, ExceptionHandler.class); Assert.state(ann != null, "No ExceptionHandler annotation"); - result.addAll(Arrays.asList(ann.value())); + return ann; } - private void addExceptionMapping(Class exceptionType, Method method) { - Method oldMethod = this.mappedMethods.put(exceptionType, method); - if (oldMethod != null && !oldMethod.equals(method)) { + private void addExceptionMapping(ExceptionMapping mapping, ExceptionHandlerMappingInfo mappingInfo) { + ExceptionHandlerMappingInfo oldMapping = this.mappedMethods.put(mapping, mappingInfo); + if (oldMapping != null && !oldMapping.getHandlerMethod().equals(mappingInfo.getHandlerMethod())) { throw new IllegalStateException("Ambiguous @ExceptionHandler method mapped for [" + - exceptionType + "]: {" + oldMethod + ", " + method + "}"); + mapping + "]: {" + oldMapping.getHandlerMethod() + ", " + mappingInfo.getHandlerMethod() + "}"); } } @@ -131,7 +162,8 @@ public boolean hasExceptionMappings() { */ @Nullable public Method resolveMethod(Exception exception) { - return resolveMethodByThrowable(exception); + ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMapping(exception, MediaType.ALL); + return (mappingInfo != null) ? mappingInfo.getHandlerMethod() : null; } /** @@ -143,61 +175,126 @@ public Method resolveMethod(Exception exception) { */ @Nullable public Method resolveMethodByThrowable(Throwable exception) { - Method method = resolveMethodByExceptionType(exception.getClass()); - if (method == null) { + ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMapping(exception, MediaType.ALL); + return (mappingInfo != null) ? mappingInfo.getHandlerMethod() : null; + } + + /** + * Find a {@link Method} to handle the given Throwable for the requested {@link MediaType}. + *

        Uses {@link ExceptionDepthComparator} and {@link MediaType#isMoreSpecific(MimeType)} + * if more than one match is found. + * @param exception the exception + * @param mediaType the media type requested by the HTTP client + * @return a Method to handle the exception, or {@code null} if none found + * @since 6.2 + */ + @Nullable + public ExceptionHandlerMappingInfo resolveExceptionMapping(Throwable exception, MediaType mediaType) { + ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMappingByExceptionType(exception.getClass(), mediaType); + if (mappingInfo == null) { Throwable cause = exception.getCause(); if (cause != null) { - method = resolveMethodByThrowable(cause); + mappingInfo = resolveExceptionMapping(cause, mediaType); } } - return method; + return mappingInfo; } /** * Find a {@link Method} to handle the given exception type. This can be - * useful if an {@link Exception} instance is not available (e.g. for tools). + * useful if an {@link Exception} instance is not available (for example, for tools). *

        Uses {@link ExceptionDepthComparator} if more than one match is found. * @param exceptionType the exception type * @return a Method to handle the exception, or {@code null} if none found */ @Nullable public Method resolveMethodByExceptionType(Class exceptionType) { - Method method = this.exceptionLookupCache.get(exceptionType); - if (method == null) { - method = getMappedMethod(exceptionType); - this.exceptionLookupCache.put(exceptionType, method); - } - return (method != NO_MATCHING_EXCEPTION_HANDLER_METHOD ? method : null); + ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMappingByExceptionType(exceptionType, MediaType.ALL); + return (mappingInfo != null) ? mappingInfo.getHandlerMethod() : null; + } + + /** + * Find a {@link Method} to handle the given exception type and media type. + * This can be useful if an {@link Exception} instance is not available (for example, for tools). + * @param exceptionType the exception type + * @param mediaType the media type requested by the HTTP client + * @return a Method to handle the exception, or {@code null} if none found + */ + @Nullable + public ExceptionHandlerMappingInfo resolveExceptionMappingByExceptionType(Class exceptionType, MediaType mediaType) { + ExceptionHandlerMappingInfo mappingInfo = this.lookupCache.get(new ExceptionMapping(exceptionType, mediaType)); + return (mappingInfo != NO_MATCHING_EXCEPTION_HANDLER ? mappingInfo : null); } /** * Return the {@link Method} mapped to the given exception type, or - * {@link #NO_MATCHING_EXCEPTION_HANDLER_METHOD} if none. + * {@link #NO_MATCHING_EXCEPTION_HANDLER} if none. */ @Nullable - private Method getMappedMethod(Class exceptionType) { - List> matches = new ArrayList<>(); - for (Class mappedException : this.mappedMethods.keySet()) { - if (mappedException.isAssignableFrom(exceptionType)) { - matches.add(mappedException); + private ExceptionHandlerMappingInfo getMappedMethod(Class exceptionType, MediaType mediaType) { + List matches = new ArrayList<>(); + for (ExceptionMapping mappingInfo : this.mappedMethods.keySet()) { + if (mappingInfo.exceptionType().isAssignableFrom(exceptionType) && mappingInfo.mediaType().isCompatibleWith(mediaType)) { + matches.add(mappingInfo); } } if (!matches.isEmpty()) { if (matches.size() > 1) { - matches.sort(new ExceptionDepthComparator(exceptionType)); + matches.sort(new ExceptionMapingComparator(exceptionType, mediaType)); } return this.mappedMethods.get(matches.get(0)); } else { - return NO_MATCHING_EXCEPTION_HANDLER_METHOD; + return NO_MATCHING_EXCEPTION_HANDLER; } } /** - * For the {@link #NO_MATCHING_EXCEPTION_HANDLER_METHOD} constant. + * For the {@link #NO_MATCHING_EXCEPTION_HANDLER} constant. */ @SuppressWarnings("unused") private void noMatchingExceptionHandler() { } + private record ExceptionMapping(Class exceptionType, MediaType mediaType) { + + @Override + public String toString() { + return "ExceptionHandler{" + + "exceptionType=" + this.exceptionType.getCanonicalName() + + ", mediaType=" + this.mediaType + + '}'; + } + } + + private static class ExceptionMapingComparator implements Comparator { + + private final ExceptionDepthComparator exceptionDepthComparator; + + private final MediaType mediaType; + + public ExceptionMapingComparator(Class exceptionType, MediaType mediaType) { + this.exceptionDepthComparator = new ExceptionDepthComparator(exceptionType); + this.mediaType = mediaType; + } + + @Override + public int compare(ExceptionMapping o1, ExceptionMapping o2) { + int result = this.exceptionDepthComparator.compare(o1.exceptionType(), o2.exceptionType()); + if (result != 0) { + return result; + } + if(o1.mediaType.equals(this.mediaType)) { + return -1; + } + if(o2.mediaType.equals(this.mediaType)) { + return 1; + } + if (o1.mediaType.equals(o2.mediaType)) { + return 0; + } + return (o1.mediaType.isMoreSpecific(o2.mediaType)) ? -1 : 1; + } + } + } diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/HandlerMethodValidationException.java b/spring-web/src/main/java/org/springframework/web/method/annotation/HandlerMethodValidationException.java index dddc70e42b20..019576c4d1ad 100644 --- a/spring-web/src/main/java/org/springframework/web/method/annotation/HandlerMethodValidationException.java +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/HandlerMethodValidationException.java @@ -22,6 +22,7 @@ import java.util.function.Predicate; import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceResolvable; import org.springframework.core.MethodParameter; import org.springframework.http.HttpStatus; import org.springframework.lang.Nullable; @@ -107,8 +108,13 @@ public boolean isForReturnValue() { } @Override - public List getAllValidationResults() { - return this.validationResult.getAllValidationResults(); + public List getParameterValidationResults() { + return this.validationResult.getParameterValidationResults(); + } + + @Override + public List getCrossParameterValidationResults() { + return this.validationResult.getCrossParameterValidationResults(); } /** @@ -116,7 +122,7 @@ public List getAllValidationResults() { * through callback methods organized by controller method parameter type. */ public void visitResults(Visitor visitor) { - for (ParameterValidationResult result : getAllValidationResults()) { + for (ParameterValidationResult result : getParameterValidationResults()) { MethodParameter param = result.getMethodParameter(); CookieValue cookieValue = param.getParameterAnnotation(CookieValue.class); if (cookieValue != null) { diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/HandlerMethodValidator.java b/spring-web/src/main/java/org/springframework/web/method/annotation/HandlerMethodValidator.java index 17ae0da5d63f..e854fcab751a 100644 --- a/spring-web/src/main/java/org/springframework/web/method/annotation/HandlerMethodValidator.java +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/HandlerMethodValidator.java @@ -98,7 +98,7 @@ public void applyArgumentValidation( } } } - if (result.getAllValidationResults().size() == bindingResultCount) { + if (result.getParameterValidationResults().size() == bindingResultCount) { return; } } diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/MapMethodProcessor.java b/spring-web/src/main/java/org/springframework/web/method/annotation/MapMethodProcessor.java index ba47fe3d4258..8f8cb85489c2 100644 --- a/spring-web/src/main/java/org/springframework/web/method/annotation/MapMethodProcessor.java +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/MapMethodProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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.core.MethodParameter; import org.springframework.lang.Nullable; +import org.springframework.ui.ModelMap; import org.springframework.util.Assert; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; @@ -42,7 +43,9 @@ public class MapMethodProcessor implements HandlerMethodArgumentResolver, Handle @Override public boolean supportsParameter(MethodParameter parameter) { - return (Map.class.isAssignableFrom(parameter.getParameterType()) && + // We don't support any type of Map + Class type = parameter.getParameterType(); + return ((type.isAssignableFrom(Map.class) || ModelMap.class.isAssignableFrom(type)) && parameter.getParameterAnnotations().length == 0); } diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/ModelMethodProcessor.java b/spring-web/src/main/java/org/springframework/web/method/annotation/ModelMethodProcessor.java index 2aef369c69a6..a68931b7cbd1 100644 --- a/spring-web/src/main/java/org/springframework/web/method/annotation/ModelMethodProcessor.java +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/ModelMethodProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +65,8 @@ public void handleReturnValue(@Nullable Object returnValue, MethodParameter retu if (returnValue == null) { return; } - else if (returnValue instanceof Model model) { + + if (returnValue instanceof Model model) { mavContainer.addAllAttributes(model.asMap()); } else { diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/SessionAttributesHandler.java b/spring-web/src/main/java/org/springframework/web/method/annotation/SessionAttributesHandler.java index 6cabf637457b..b9071f448436 100644 --- a/spring-web/src/main/java/org/springframework/web/method/annotation/SessionAttributesHandler.java +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/SessionAttributesHandler.java @@ -64,7 +64,7 @@ public class SessionAttributesHandler { private final Set> attributeTypes = new HashSet<>(); - private final Set knownAttributeNames = Collections.newSetFromMap(new ConcurrentHashMap<>(4)); + private final Set knownAttributeNames = ConcurrentHashMap.newKeySet(4); private final SessionAttributeStore sessionAttributeStore; diff --git a/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java b/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java index 57d49897e11c..6f14f487aee8 100644 --- a/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java +++ b/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java @@ -30,6 +30,8 @@ import kotlin.reflect.full.KClasses; import kotlin.reflect.jvm.KCallablesJvm; import kotlin.reflect.jvm.ReflectJvmMapping; +import reactor.core.publisher.Mono; +import reactor.core.publisher.SynchronousSink; import org.springframework.context.MessageSource; import org.springframework.core.CoroutinesUtils; @@ -124,7 +126,7 @@ public void setHandlerMethodArgumentResolvers(HandlerMethodArgumentResolverCompo /** * Set the ParameterNameDiscoverer for resolving parameter names when needed - * (e.g. default request attribute name). + * (for example, default request attribute name). *

        Default is a {@link org.springframework.core.DefaultParameterNameDiscoverer}. */ public void setParameterNameDiscoverer(ParameterNameDiscoverer parameterNameDiscoverer) { @@ -288,7 +290,8 @@ else if (targetException instanceof Exception exception) { * @since 6.0 */ protected Object invokeSuspendingFunction(Method method, Object target, Object[] args) { - return CoroutinesUtils.invokeSuspendingFunction(method, target, args); + Object result = CoroutinesUtils.invokeSuspendingFunction(method, target, args); + return (result instanceof Mono mono ? mono.handle(KotlinDelegate::handleResult) : result); } @@ -298,14 +301,14 @@ protected Object invokeSuspendingFunction(Method method, Object target, Object[] private static class KotlinDelegate { @Nullable - @SuppressWarnings({"deprecation", "DataFlowIssue"}) - public static Object invokeFunction(Method method, Object target, Object[] args) throws InvocationTargetException, IllegalAccessException { + @SuppressWarnings("DataFlowIssue") + public static Object invokeFunction(Method method, Object target, Object[] args) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException { KFunction function = ReflectJvmMapping.getKotlinFunction(method); // For property accessors if (function == null) { return method.invoke(target, args); } - if (method.isAccessible() && !KCallablesJvm.isAccessible(function)) { + if (!KCallablesJvm.isAccessible(function)) { KCallablesJvm.setAccessible(function, true); } Map argMap = CollectionUtils.newHashMap(args.length + 1); @@ -332,8 +335,34 @@ public static Object invokeFunction(Method method, Object target, Object[] args) } } Object result = function.callBy(argMap); + if (result != null && KotlinDetector.isInlineClass(result.getClass())) { + result = unbox(result); + } return (result == Unit.INSTANCE ? null : result); } + + private static void handleResult(Object result, SynchronousSink sink) { + if (KotlinDetector.isInlineClass(result.getClass())) { + try { + Object unboxed = unbox(result); + if (unboxed != Unit.INSTANCE) { + sink.next(unboxed); + } + sink.complete(); + } + catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException ex) { + sink.error(ex); + } + } + else { + sink.next(result); + sink.complete(); + } + } + + private static Object unbox(Object result) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException { + return result.getClass().getDeclaredMethod("unbox-impl").invoke(result); + } } } diff --git a/spring-web/src/main/java/org/springframework/web/method/support/ModelAndViewContainer.java b/spring-web/src/main/java/org/springframework/web/method/support/ModelAndViewContainer.java index bf5ae9f91ef8..7f2db8d3dcdc 100644 --- a/spring-web/src/main/java/org/springframework/web/method/support/ModelAndViewContainer.java +++ b/spring-web/src/main/java/org/springframework/web/method/support/ModelAndViewContainer.java @@ -165,7 +165,7 @@ private boolean useDefaultModel() { * returns either the "default" model (template rendering) or the "redirect" * model (redirect URL preparation). Use of this method may be needed for * advanced cases when access to the "default" model is needed regardless, - * e.g. to save model attributes specified via {@code @SessionAttributes}. + * for example, to save model attributes specified via {@code @SessionAttributes}. * @return the default model (never {@code null}) * @since 4.1.4 */ @@ -184,7 +184,7 @@ public void setRedirectModel(ModelMap redirectModel) { } /** - * Whether the controller has returned a redirect instruction, e.g. a + * Whether the controller has returned a redirect instruction, for example, a * "redirect:" prefixed view name, a RedirectView instance, etc. */ public void setRedirectModelScenario(boolean redirectModelScenario) { @@ -253,7 +253,7 @@ public SessionStatus getSessionStatus() { } /** - * Whether the request has been handled fully within the handler, e.g. + * Whether the request has been handled fully within the handler, for example, * {@code @ResponseBody} method, and therefore view resolution is not * necessary. This flag can also be set when controller methods declare an * argument of type {@code ServletResponse} or {@code OutputStream}). diff --git a/spring-web/src/main/java/org/springframework/web/multipart/MultipartFile.java b/spring-web/src/main/java/org/springframework/web/multipart/MultipartFile.java index 0cfda77c558c..34ad8274c263 100644 --- a/spring-web/src/main/java/org/springframework/web/multipart/MultipartFile.java +++ b/spring-web/src/main/java/org/springframework/web/multipart/MultipartFile.java @@ -124,7 +124,7 @@ default Resource getResource() { * in order to work with any storage mechanism. *

        NOTE: Depending on the underlying provider, temporary storage * may be container-dependent, including the base directory for relative - * destinations specified here (e.g. with Servlet multipart handling). + * destinations specified here (for example, with Servlet multipart handling). * For absolute destinations, the target file may get renamed/moved from its * temporary location or newly copied, even if a temporary copy already exists. * @param dest the destination file (typically absolute) diff --git a/spring-web/src/main/java/org/springframework/web/multipart/MultipartFileResource.java b/spring-web/src/main/java/org/springframework/web/multipart/MultipartFileResource.java index 141ca407f093..d710062e0680 100644 --- a/spring-web/src/main/java/org/springframework/web/multipart/MultipartFileResource.java +++ b/spring-web/src/main/java/org/springframework/web/multipart/MultipartFileResource.java @@ -65,6 +65,7 @@ public long contentLength() { } @Override + @Nullable public String getFilename() { return this.multipartFile.getOriginalFilename(); } diff --git a/spring-web/src/main/java/org/springframework/web/multipart/MultipartHttpServletRequest.java b/spring-web/src/main/java/org/springframework/web/multipart/MultipartHttpServletRequest.java index 40f678469da8..7d2237d85961 100644 --- a/spring-web/src/main/java/org/springframework/web/multipart/MultipartHttpServletRequest.java +++ b/spring-web/src/main/java/org/springframework/web/multipart/MultipartHttpServletRequest.java @@ -62,7 +62,7 @@ public interface MultipartHttpServletRequest extends HttpServletRequest, Multipa /** * Return the headers for the specified part of the multipart request. *

        If the underlying implementation supports access to part headers, - * then all headers are returned. Otherwise, e.g. for a file upload, the + * then all headers are returned. Otherwise, for example, for a file upload, the * returned headers may expose a 'Content-Type' if available. */ @Nullable diff --git a/spring-web/src/main/java/org/springframework/web/multipart/support/AbstractMultipartHttpServletRequest.java b/spring-web/src/main/java/org/springframework/web/multipart/support/AbstractMultipartHttpServletRequest.java index a7ba4dff74e7..1299f15c7f97 100644 --- a/spring-web/src/main/java/org/springframework/web/multipart/support/AbstractMultipartHttpServletRequest.java +++ b/spring-web/src/main/java/org/springframework/web/multipart/support/AbstractMultipartHttpServletRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -102,7 +102,7 @@ public List getFiles(String name) { @Override public Map getFileMap() { - return getMultipartFiles().toSingleValueMap(); + return getMultipartFiles().asSingleValueMap(); } @Override @@ -137,6 +137,7 @@ protected final void setMultipartFiles(MultiValueMap mult * lazily initializing it if necessary. * @see #initializeMultipart() */ + @SuppressWarnings("NullAway") protected MultiValueMap getMultipartFiles() { if (this.multipartFiles == null) { initializeMultipart(); diff --git a/spring-web/src/main/java/org/springframework/web/multipart/support/DefaultMultipartHttpServletRequest.java b/spring-web/src/main/java/org/springframework/web/multipart/support/DefaultMultipartHttpServletRequest.java index f7a3ebac9a6e..609837234ba0 100644 --- a/spring-web/src/main/java/org/springframework/web/multipart/support/DefaultMultipartHttpServletRequest.java +++ b/spring-web/src/main/java/org/springframework/web/multipart/support/DefaultMultipartHttpServletRequest.java @@ -169,6 +169,7 @@ protected final void setMultipartParameters(Map multipartParam * lazily initializing it if necessary. * @see #initializeMultipart() */ + @SuppressWarnings("NullAway") protected Map getMultipartParameters() { if (this.multipartParameters == null) { initializeMultipart(); @@ -189,6 +190,7 @@ protected final void setMultipartParameterContentTypes(Map multi * lazily initializing it if necessary. * @see #initializeMultipart() */ + @SuppressWarnings("NullAway") protected Map getMultipartParameterContentTypes() { if (this.multipartParameterContentTypes == null) { initializeMultipart(); diff --git a/spring-web/src/main/java/org/springframework/web/multipart/support/MissingServletRequestPartException.java b/spring-web/src/main/java/org/springframework/web/multipart/support/MissingServletRequestPartException.java index acb7cfb94445..e9ffe0604e38 100644 --- a/spring-web/src/main/java/org/springframework/web/multipart/support/MissingServletRequestPartException.java +++ b/spring-web/src/main/java/org/springframework/web/multipart/support/MissingServletRequestPartException.java @@ -28,7 +28,7 @@ * Signals the part of a "multipart/form-data" request, identified by name * could not be found. This may be because the request is not a multipart * request, or a part with that name is not present, or because the application - * is not configured correctly for processing multipart requests, e.g. there + * is not configured correctly for processing multipart requests, for example, there * is no {@link MultipartResolver}. * *

        Note: This exception does not extend from diff --git a/spring-web/src/main/java/org/springframework/web/multipart/support/MultipartFilter.java b/spring-web/src/main/java/org/springframework/web/multipart/support/MultipartFilter.java index a5bdabc71b75..edc9657c310c 100644 --- a/spring-web/src/main/java/org/springframework/web/multipart/support/MultipartFilter.java +++ b/spring-web/src/main/java/org/springframework/web/multipart/support/MultipartFilter.java @@ -54,7 +54,7 @@ *

        Note: This filter is an alternative to using DispatcherServlet's * MultipartResolver support, for example for web applications with custom web views * which do not use Spring's web MVC, or for custom filters applied before a Spring MVC - * DispatcherServlet (e.g. {@link org.springframework.web.filter.HiddenHttpMethodFilter}). + * DispatcherServlet (for example, {@link org.springframework.web.filter.HiddenHttpMethodFilter}). * In any case, this filter should not be combined with servlet-specific multipart resolution. * * @author Juergen Hoeller diff --git a/spring-web/src/main/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequest.java b/spring-web/src/main/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequest.java index cb9226f1d1dc..d2810985c2fe 100644 --- a/spring-web/src/main/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequest.java +++ b/spring-web/src/main/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequest.java @@ -38,6 +38,7 @@ import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; import org.springframework.util.FileCopyUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -92,7 +93,7 @@ public StandardMultipartHttpServletRequest(HttpServletRequest request, boolean l private void parseRequest(HttpServletRequest request) { try { Collection parts = request.getParts(); - this.multipartParameterNames = new LinkedHashSet<>(parts.size()); + this.multipartParameterNames = CollectionUtils.newLinkedHashSet(parts.size()); MultiValueMap files = new LinkedMultiValueMap<>(parts.size()); for (Part part : parts) { String headerValue = part.getHeader(HttpHeaders.CONTENT_DISPOSITION); @@ -138,6 +139,7 @@ protected void initializeMultipart() { } @Override + @SuppressWarnings("NullAway") public Enumeration getParameterNames() { if (this.multipartParameterNames == null) { initializeMultipart(); @@ -147,7 +149,7 @@ public Enumeration getParameterNames() { } // Servlet getParameterNames() not guaranteed to include multipart form items - // (e.g. on WebLogic 12) -> need to merge them here to be on the safe side + // (for example, on WebLogic 12) -> need to merge them here to be on the safe side Set paramNames = new LinkedHashSet<>(); Enumeration paramEnum = super.getParameterNames(); while (paramEnum.hasMoreElements()) { @@ -158,6 +160,7 @@ public Enumeration getParameterNames() { } @Override + @SuppressWarnings("NullAway") public Map getParameterMap() { if (this.multipartParameterNames == null) { initializeMultipart(); @@ -167,7 +170,7 @@ public Map getParameterMap() { } // Servlet getParameterMap() not guaranteed to include multipart form items - // (e.g. on WebLogic 12) -> need to merge them here to be on the safe side + // (for example, on WebLogic 12) -> need to merge them here to be on the safe side Map paramMap = new LinkedHashMap<>(super.getParameterMap()); for (String paramName : this.multipartParameterNames) { if (!paramMap.containsKey(paramName)) { @@ -267,7 +270,7 @@ public void transferTo(File dest) throws IOException, IllegalStateException { if (dest.isAbsolute() && !dest.exists()) { // Servlet Part.write is not guaranteed to support absolute file paths: // may translate the given path to a relative location within a temp dir - // (e.g. on Jetty whereas Tomcat and Undertow detect absolute paths). + // (for example, on Jetty whereas Tomcat and Undertow detect absolute paths). // At least we offloaded the file from memory storage; it'll get deleted // from the temp dir eventually in any case. And for our user's purposes, // we can manually copy it to the requested location as a fallback. diff --git a/spring-web/src/main/java/org/springframework/web/multipart/support/StandardServletMultipartResolver.java b/spring-web/src/main/java/org/springframework/web/multipart/support/StandardServletMultipartResolver.java index cbe80f2a4549..92b3c78d5f87 100644 --- a/spring-web/src/main/java/org/springframework/web/multipart/support/StandardServletMultipartResolver.java +++ b/spring-web/src/main/java/org/springframework/web/multipart/support/StandardServletMultipartResolver.java @@ -88,7 +88,7 @@ public void setResolveLazily(boolean resolveLazily) { * specification, only kicking in for "multipart/form-data" requests. *

        Default is "false", trying to process any request with a "multipart/" * content type as far as the underlying Servlet container supports it - * (which works on e.g. Tomcat but not on Jetty). For consistent portability + * (which works on, for example, Tomcat but not on Jetty). For consistent portability * and in particular for consistent custom handling of non-form multipart * request types outside of Spring's {@link MultipartResolver} mechanism, * switch this flag to "true": Only "multipart/form-data" requests will be diff --git a/spring-web/src/main/java/org/springframework/web/server/MissingRequestValueException.java b/spring-web/src/main/java/org/springframework/web/server/MissingRequestValueException.java index 281676507a71..0cc87bf4cd75 100644 --- a/spring-web/src/main/java/org/springframework/web/server/MissingRequestValueException.java +++ b/spring-web/src/main/java/org/springframework/web/server/MissingRequestValueException.java @@ -46,7 +46,7 @@ public MissingRequestValueException(String name, Class type, String label, Me /** - * Return the name of the missing value, e.g. the name of the missing request + * Return the name of the missing value, for example, the name of the missing request * header, or cookie, etc. */ public String getName() { @@ -61,7 +61,7 @@ public Class getType() { } /** - * Return a label that describes the request value, e.g. "request header", + * Return a label that describes the request value, for example, "request header", * "cookie value", etc. Use this to create a custom message. */ public String getLabel() { diff --git a/spring-web/src/main/java/org/springframework/web/server/PayloadTooLargeException.java b/spring-web/src/main/java/org/springframework/web/server/PayloadTooLargeException.java new file mode 100644 index 000000000000..5dc9cf412784 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/server/PayloadTooLargeException.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.server; + + +import org.springframework.http.HttpStatus; +import org.springframework.lang.Nullable; + +/** + * Exception for errors that fit response status 413 (payload too large) for use in + * Spring Web applications. + * + * @author Kim Bosung + * @since 6.2 + */ +@SuppressWarnings("serial") +public class PayloadTooLargeException extends ResponseStatusException { + + public PayloadTooLargeException(@Nullable Throwable cause) { + super(HttpStatus.PAYLOAD_TOO_LARGE, null, cause); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/server/ResponseStatusException.java b/spring-web/src/main/java/org/springframework/web/server/ResponseStatusException.java index 8e9c039aae71..ace7a1b97fc9 100644 --- a/spring-web/src/main/java/org/springframework/web/server/ResponseStatusException.java +++ b/spring-web/src/main/java/org/springframework/web/server/ResponseStatusException.java @@ -109,7 +109,7 @@ public String getReason() { } /** - * Return headers to add to the error response, e.g. "Allow", "Accept", etc. + * Return headers to add to the error response, for example, "Allow", "Accept", etc. *

        By default, delegates to {@link #getResponseHeaders()} for backwards * compatibility. */ @@ -120,7 +120,7 @@ public HttpHeaders getHeaders() { /** * Return headers associated with the exception that should be added to the - * error response, e.g. "Allow", "Accept", etc. + * error response, for example, "Allow", "Accept", etc. *

        The default implementation in this class returns empty headers. * @since 5.1.13 * @deprecated as of 6.0 in favor of {@link #getHeaders()} diff --git a/spring-web/src/main/java/org/springframework/web/server/ServerWebInputException.java b/spring-web/src/main/java/org/springframework/web/server/ServerWebInputException.java index 530135b0924a..e0ba0ea2435d 100644 --- a/spring-web/src/main/java/org/springframework/web/server/ServerWebInputException.java +++ b/spring-web/src/main/java/org/springframework/web/server/ServerWebInputException.java @@ -22,7 +22,7 @@ /** * Exception for errors that fit response status 400 (bad request) for use in - * Spring Web applications. The exception provides additional fields (e.g. + * Spring Web applications. The exception provides additional fields (for example, * an optional {@link MethodParameter} if related to the error). * * @author Rossen Stoyanchev diff --git a/spring-web/src/main/java/org/springframework/web/server/WebFilter.java b/spring-web/src/main/java/org/springframework/web/server/WebFilter.java index 5180d6138f24..261eeaa85565 100644 --- a/spring-web/src/main/java/org/springframework/web/server/WebFilter.java +++ b/spring-web/src/main/java/org/springframework/web/server/WebFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2015 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java b/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java index 2c6f73bfbfcb..0500180897b1 100644 --- a/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java +++ b/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +33,7 @@ import org.springframework.context.i18n.LocaleContext; import org.springframework.core.ResolvableType; import org.springframework.core.codec.Hints; +import org.springframework.http.ETag; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -42,6 +43,7 @@ import org.springframework.http.codec.HttpMessageReader; import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.http.codec.multipart.Part; +import org.springframework.http.server.reactive.AbstractServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.lang.Nullable; @@ -137,6 +139,10 @@ public DefaultServerWebExchange(ServerHttpRequest request, ServerHttpResponse re this.formDataMono = initFormData(request, codecConfigurer, getLogPrefix()); this.multipartDataMono = initMultipartData(codecConfigurer, getLogPrefix()); this.applicationContext = applicationContext; + + if (request instanceof AbstractServerHttpRequest abstractServerHttpRequest) { + abstractServerHttpRequest.setAttributesSupplier(() -> this.attributes); + } } private static Mono> initFormData(ServerHttpRequest request, @@ -324,10 +330,11 @@ private boolean validateIfMatch(@Nullable String eTag) { if (SAFE_METHODS.contains(getRequest().getMethod())) { return false; } - if (CollectionUtils.isEmpty(getRequestHeaders().get(HttpHeaders.IF_MATCH))) { + List values = getRequestHeaders().getOrEmpty(HttpHeaders.IF_MATCH); + if (CollectionUtils.isEmpty(values)) { return false; } - this.notModified = matchRequestedETags(getRequestHeaders().getIfMatch(), eTag, false); + this.notModified = matchRequestedETags(values, eTag, false); } catch (IllegalArgumentException ex) { return false; @@ -335,60 +342,25 @@ private boolean validateIfMatch(@Nullable String eTag) { return true; } - private boolean matchRequestedETags(List requestedETags, @Nullable String eTag, boolean weakCompare) { - eTag = padEtagIfNecessary(eTag); - for (String clientEtag : requestedETags) { - // only consider "lost updates" checks for unsafe HTTP methods - if ("*".equals(clientEtag) && StringUtils.hasLength(eTag) - && !SAFE_METHODS.contains(getRequest().getMethod())) { - return false; - } - // Compare weak/strong ETags as per https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.3 - if (weakCompare) { - if (eTagWeakMatch(eTag, clientEtag)) { - return false; - } - } - else { - if (eTagStrongMatch(eTag, clientEtag)) { - return false; + private boolean matchRequestedETags(List requestedETagValues, @Nullable String tag, boolean weakCompare) { + if (StringUtils.hasLength(tag)) { + ETag eTag = ETag.create(tag); + boolean isNotSafeMethod = !SAFE_METHODS.contains(getRequest().getMethod()); + for (String eTagValue : requestedETagValues) { + for (ETag requestedETag : ETag.parse(eTagValue)) { + // only consider "lost updates" checks for unsafe HTTP methods + if (requestedETag.isWildcard() && isNotSafeMethod) { + return false; + } + if (requestedETag.compare(eTag, !weakCompare)) { + return false; + } } } } return true; } - @Nullable - private String padEtagIfNecessary(@Nullable String etag) { - if (!StringUtils.hasLength(etag)) { - return etag; - } - if ((etag.startsWith("\"") || etag.startsWith("W/\"")) && etag.endsWith("\"")) { - return etag; - } - return "\"" + etag + "\""; - } - - private boolean eTagStrongMatch(@Nullable String first, @Nullable String second) { - if (!StringUtils.hasLength(first) || first.startsWith("W/")) { - return false; - } - return first.equals(second); - } - - private boolean eTagWeakMatch(@Nullable String first, @Nullable String second) { - if (!StringUtils.hasLength(first) || !StringUtils.hasLength(second)) { - return false; - } - if (first.startsWith("W/")) { - first = first.substring(2); - } - if (second.startsWith("W/")) { - second = second.substring(2); - } - return first.equals(second); - } - private void updateResponseStateChanging(@Nullable String eTag, Instant lastModified) { if (this.notModified) { getResponse().setStatusCode(HttpStatus.PRECONDITION_FAILED); @@ -403,7 +375,8 @@ private boolean validateIfNoneMatch(@Nullable String eTag) { if (CollectionUtils.isEmpty(getRequestHeaders().get(HttpHeaders.IF_NONE_MATCH))) { return false; } - this.notModified = !matchRequestedETags(getRequestHeaders().getIfNoneMatch(), eTag, true); + List values = getRequestHeaders().getOrEmpty(HttpHeaders.IF_NONE_MATCH); + this.notModified = !matchRequestedETags(values, eTag, true); } catch (IllegalArgumentException ex) { return false; @@ -420,13 +393,13 @@ private void updateResponseIdempotent(@Nullable String eTag, Instant lastModifie addCachingResponseHeaders(eTag, lastModified); } - private void addCachingResponseHeaders(@Nullable String eTag, Instant lastModified) { + private void addCachingResponseHeaders(@Nullable String tag, Instant lastModified) { if (SAFE_METHODS.contains(getRequest().getMethod())) { if (lastModified.isAfter(Instant.EPOCH) && getResponseHeaders().getLastModified() == -1) { getResponseHeaders().setLastModified(lastModified.toEpochMilli()); } - if (StringUtils.hasLength(eTag) && getResponseHeaders().getETag() == null) { - getResponseHeaders().setETag(padEtagIfNecessary(eTag)); + if (StringUtils.hasLength(tag) && getResponseHeaders().getETag() == null) { + getResponseHeaders().setETag(tag); } } } diff --git a/spring-web/src/main/java/org/springframework/web/server/adapter/HttpWebHandlerAdapter.java b/spring-web/src/main/java/org/springframework/web/server/adapter/HttpWebHandlerAdapter.java index a645ea82c0e6..10659c9bd652 100644 --- a/spring-web/src/main/java/org/springframework/web/server/adapter/HttpWebHandlerAdapter.java +++ b/spring-web/src/main/java/org/springframework/web/server/adapter/HttpWebHandlerAdapter.java @@ -152,6 +152,7 @@ public void setCodecConfigurer(ServerCodecConfigurer codecConfigurer) { /** * Return the configured {@link ServerCodecConfigurer}. */ + @SuppressWarnings("NullAway") public ServerCodecConfigurer getCodecConfigurer() { if (this.codecConfigurer == null) { setCodecConfigurer(ServerCodecConfigurer.create()); 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 68f0992936db..b68e25a7b62c 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -243,7 +243,6 @@ public static WebHttpHandlerBuilder applicationContext(ApplicationContext contex public WebHttpHandlerBuilder filter(WebFilter... filters) { if (!ObjectUtils.isEmpty(filters)) { this.filters.addAll(Arrays.asList(filters)); - updateFilters(); } return this; } @@ -254,29 +253,9 @@ public WebHttpHandlerBuilder filter(WebFilter... filters) { */ public WebHttpHandlerBuilder filters(Consumer> consumer) { consumer.accept(this.filters); - updateFilters(); return this; } - private void updateFilters() { - if (this.filters.isEmpty()) { - return; - } - - List filtersToUse = this.filters.stream() - .peek(filter -> { - if (filter instanceof ForwardedHeaderTransformer forwardedHeaderTransformerFilter - && this.forwardedHeaderTransformer == null) { - this.forwardedHeaderTransformer = forwardedHeaderTransformerFilter; - } - }) - .filter(filter -> !(filter instanceof ForwardedHeaderTransformer)) - .toList(); - - this.filters.clear(); - this.filters.addAll(filtersToUse); - } - /** * Add the given exception handler(s). * @param handlers the exception handler(s) diff --git a/spring-web/src/main/java/org/springframework/web/server/handler/ExceptionHandlingWebHandler.java b/spring-web/src/main/java/org/springframework/web/server/handler/ExceptionHandlingWebHandler.java index 4e80dff39935..ab69be912d23 100644 --- a/spring-web/src/main/java/org/springframework/web/server/handler/ExceptionHandlingWebHandler.java +++ b/spring-web/src/main/java/org/springframework/web/server/handler/ExceptionHandlingWebHandler.java @@ -91,7 +91,7 @@ public Mono handle(ServerWebExchange exchange) { /** * WebExceptionHandler to insert a checkpoint with current URL information. * Must be the first in order to ensure we catch the error signal before - * the exception is handled and e.g. turned into an error response. + * the exception is handled and, for example, turned into an error response. * @since 5.2 */ private static class CheckpointInsertingHandler implements WebExceptionHandler { diff --git a/spring-web/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java b/spring-web/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java index ecc1557d6a82..b0243ef603b0 100644 --- a/spring-web/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java +++ b/spring-web/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java @@ -82,7 +82,7 @@ public int getMaxSessions() { * Configure the {@link Clock} to use to set lastAccessTime on every created * session and to calculate if it is expired. *

        This may be useful to align to different timezone or to set the clock - * back in a test, e.g. {@code Clock.offset(clock, Duration.ofMinutes(-31))} + * back in a test, for example, {@code Clock.offset(clock, Duration.ofMinutes(-31))} * in order to simulate session expiration. *

        By default this is {@code Clock.system(ZoneId.of("GMT"))}. * @param clock the clock to use @@ -189,6 +189,7 @@ public InMemoryWebSession(Instant creationTime) { } @Override + @SuppressWarnings("NullAway") public String getId() { return this.id.get(); } @@ -224,6 +225,7 @@ public void start() { } @Override + @SuppressWarnings("NullAway") public boolean isStarted() { return this.state.get().equals(State.STARTED) || !getAttributes().isEmpty(); } @@ -252,6 +254,7 @@ public Mono invalidate() { } @Override + @SuppressWarnings("NullAway") public Mono save() { checkMaxSessionsLimit(); @@ -289,6 +292,7 @@ public boolean isExpired() { return isExpired(clock.instant()); } + @SuppressWarnings("NullAway") private boolean isExpired(Instant now) { if (this.state.get().equals(State.EXPIRED)) { return true; diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java index 3409a5a78a8b..7b3c478fcb7c 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java @@ -60,4 +60,11 @@ @AliasFor(annotation = HttpExchange.class) String[] accept() default {}; + /** + * Alias for {@link HttpExchange#headers()}. + * @since 6.2 + */ + @AliasFor(annotation = HttpExchange.class) + String[] headers() default {}; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java index 4f3845ae095f..2b34bb53ee13 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java @@ -109,7 +109,7 @@ * {@link org.springframework.web.bind.annotation.RequestPart @RequestPart} * Add a request part, which may be a String (form field), * {@link org.springframework.core.io.Resource} (file part), Object (entity to be - * encoded, e.g. as JSON), {@link HttpEntity} (part content and headers), a + * encoded, for example, as JSON), {@link HttpEntity} (part content and headers), a * {@link org.springframework.http.codec.multipart.Part}, or a * {@link org.reactivestreams.Publisher} of any of the above. * ( @@ -173,4 +173,16 @@ */ String[] accept() default {}; + /** + * The additional headers to use, as an array of {@code name=value} pairs. + *

        Multiple comma-separated values are accepted, and placeholders are + * supported in these values. However, Accept and Content-Type headers are + * ignored: see {@link #accept()} and {@link #contentType()}. + *

        Supported at the type level as well as at the method level, in which + * case the method-level values override type-level values. + *

        By default, this is empty. + * @since 6.2 + */ + String[] headers() default {}; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java index d84f86101528..e36d89d6e681 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java @@ -60,4 +60,11 @@ @AliasFor(annotation = HttpExchange.class) String[] accept() default {}; + /** + * Alias for {@link HttpExchange#headers()}. + * @since 6.2 + */ + @AliasFor(annotation = HttpExchange.class) + String[] headers() default {}; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java index 44f2c1d69a74..7e2c2a461542 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java @@ -60,4 +60,11 @@ @AliasFor(annotation = HttpExchange.class) String[] accept() default {}; + /** + * Alias for {@link HttpExchange#headers()}. + * @since 6.2 + */ + @AliasFor(annotation = HttpExchange.class) + String[] headers() default {}; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java index 7fa2d0a546d2..e7d17a8017b8 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java @@ -60,4 +60,11 @@ @AliasFor(annotation = HttpExchange.class) String[] accept() default {}; + /** + * Alias for {@link HttpExchange#headers()}. + * @since 6.2 + */ + @AliasFor(annotation = HttpExchange.class) + String[] headers() default {}; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/AbstractNamedValueArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/AbstractNamedValueArgumentResolver.java index 62b25d70a7a3..3a967ed0c7d4 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/AbstractNamedValueArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/AbstractNamedValueArgumentResolver.java @@ -32,6 +32,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.ValueConstants; /** @@ -39,6 +40,7 @@ * request header, path variable, cookie, and others. * * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma * @since 6.0 */ public abstract class AbstractNamedValueArgumentResolver implements HttpServiceArgumentResolver { @@ -76,7 +78,7 @@ protected AbstractNamedValueArgumentResolver() { public boolean resolve( @Nullable Object argument, MethodParameter parameter, HttpRequestValues.Builder requestValues) { - NamedValueInfo info = getNamedValueInfo(parameter); + NamedValueInfo info = getNamedValueInfo(parameter, requestValues); if (info == null) { return false; } @@ -101,10 +103,10 @@ public boolean resolve( } @Nullable - private NamedValueInfo getNamedValueInfo(MethodParameter parameter) { + private NamedValueInfo getNamedValueInfo(MethodParameter parameter, HttpRequestValues.Metadata requestValues) { NamedValueInfo info = this.namedValueInfoCache.get(parameter); if (info == null) { - info = createNamedValueInfo(parameter); + info = createNamedValueInfo(parameter, requestValues); if (info == null) { return null; } @@ -121,6 +123,18 @@ private NamedValueInfo getNamedValueInfo(MethodParameter parameter) { @Nullable protected abstract NamedValueInfo createNamedValueInfo(MethodParameter parameter); + /** + * Variant of {@link #createNamedValueInfo(MethodParameter)} that also provides + * access to the static values set from {@code @HttpExchange} attributes. + * @since 6.2 + */ + @Nullable + protected NamedValueInfo createNamedValueInfo( + MethodParameter parameter, HttpRequestValues.Metadata metadata) { + + return createNamedValueInfo(parameter); + } + private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) { String name = info.name; if (info.name.isEmpty()) { @@ -132,7 +146,7 @@ private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValu .formatted(parameter.getNestedParameterType().getName())); } } - boolean required = (info.required && !parameter.getParameterType().equals(Optional.class)); + boolean required = (info.required && !parameter.isOptional()); String defaultValue = (ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue); return info.update(name, required, defaultValue); } @@ -178,11 +192,15 @@ private void addSingleValue( } if (this.conversionService != null && !(value instanceof String)) { + Object beforeValue = value; parameter = parameter.nestedIfOptional(); Class type = parameter.getNestedParameterType(); value = (type != Object.class && !type.isArray() ? this.conversionService.convert(value, new TypeDescriptor(parameter), STRING_TARGET_TYPE) : this.conversionService.convert(value, String.class)); + if (!StringUtils.hasText((String) value) && !required && beforeValue == null) { + value = null; + } } if (value == null) { @@ -232,7 +250,9 @@ protected static class NamedValueInfo { * @param name the name to use, possibly empty if not specified * @param required whether it is marked as required * @param defaultValue fallback value, possibly {@link ValueConstants#DEFAULT_NONE} - * @param label how it should appear in error messages, e.g. "path variable", "request header" + * @param label how it should appear in error messages, for example, "path variable", "request header" + * @param multiValued whether this argument resolver supports sending multiple values; + * if not, then multiple values are formatted as a String value */ public NamedValueInfo( String name, boolean required, @Nullable String defaultValue, String label, boolean multiValued) { diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpMethodArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpMethodArgumentResolver.java index 9a6f966f08fe..0f0258045e26 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpMethodArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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.web.service.invoker; +import java.util.Optional; + import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -41,11 +43,21 @@ public class HttpMethodArgumentResolver implements HttpServiceArgumentResolver { public boolean resolve( @Nullable Object argument, MethodParameter parameter, HttpRequestValues.Builder requestValues) { - if (!parameter.getParameterType().equals(HttpMethod.class)) { + parameter = parameter.nestedIfOptional(); + + if (!parameter.getNestedParameterType().equals(HttpMethod.class)) { return false; } - Assert.notNull(argument, "HttpMethod is required"); + if (argument instanceof Optional optionalValue) { + argument = optionalValue.orElse(null); + } + + if (argument == null) { + Assert.isTrue(parameter.isOptional(), "HttpMethod is required"); + return true; + } + HttpMethod httpMethod = (HttpMethod) argument; requestValues.setHttpMethod(httpMethod); if (logger.isTraceEnabled()) { diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java index 12598fb22a00..309aa60faf4c 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author 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,10 +23,6 @@ import java.util.List; import java.util.Map; -import org.reactivestreams.Publisher; - -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.core.ResolvableType; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -80,22 +76,6 @@ public class HttpRequestValues { private final Object bodyValue; - /** - * Constructor without UriBuilderFactory. - * @deprecated in favour of - * {@link HttpRequestValues#HttpRequestValues(HttpMethod, URI, UriBuilderFactory, String, Map, HttpHeaders, MultiValueMap, Map, Object)} - * to be removed in 6.2. - */ - @Deprecated(since = "6.1", forRemoval = true) - protected HttpRequestValues(@Nullable HttpMethod httpMethod, - @Nullable URI uri, @Nullable String uriTemplate, - Map uriVariables, - HttpHeaders headers, MultiValueMap cookies, Map attributes, - @Nullable Object bodyValue) { - - this(httpMethod, uri, null, uriTemplate, uriVariables, headers, cookies, attributes, bodyValue); - } - /** * Construct {@link HttpRequestValues}. * @since 6.1 @@ -197,40 +177,48 @@ public Object getBodyValue() { return this.bodyValue; } - /** - * Return the request body as a Publisher. - *

        This is mutually exclusive with {@link #getBodyValue()}. - * Only one of the two or neither is set. - * @deprecated in favor of {@link ReactiveHttpRequestValues#getBodyPublisher()}; - * to be removed in 6.2 - */ - @Deprecated(since = "6.1", forRemoval = true) - @Nullable - public Publisher getBody() { - throw new UnsupportedOperationException(); + + public static Builder builder() { + return new Builder(); } + /** - * Return the element type for a Publisher body. - * @deprecated in favor of {@link ReactiveHttpRequestValues#getBodyPublisherElementType()}; - * to be removed in 6.2 + * Expose static metadata from {@code @HttpExchange} annotation attributes. + * @since 6.2 */ - @Deprecated(since = "6.1", forRemoval = true) - @Nullable - public ParameterizedTypeReference getBodyElementType() { - throw new UnsupportedOperationException(); - } + public interface Metadata { + /** + * Return the HTTP method, if known. + */ + @Nullable + HttpMethod getHttpMethod(); - public static Builder builder() { - return new Builder(); + /** + * Return the URI template, if set already. + */ + @Nullable + String getUriTemplate(); + + /** + * Return the content type, if set already. + */ + @Nullable + MediaType getContentType(); + + /** + * Return the acceptable media types, if set already. + */ + @Nullable + List getAcceptMediaTypes(); } /** * Builder for {@link HttpRequestValues}. */ - public static class Builder { + public static class Builder implements Metadata { @Nullable private HttpMethod httpMethod; @@ -372,7 +360,7 @@ public Builder addRequestParameter(String name, String... values) { *

          *
        • String -- form field *
        • {@link org.springframework.core.io.Resource Resource} -- file part - *
        • Object -- content to be encoded (e.g. to JSON) + *
        • Object -- content to be encoded (for example, to JSON) *
        • {@link HttpEntity} -- part content and headers although generally it's * easier to add headers through the returned builder *
        @@ -383,17 +371,6 @@ public Builder addRequestPart(String name, Object part) { return this; } - /** - * Variant of {@link #addRequestPart(String, Object)} that allows the - * part value to be produced by a {@link Publisher}. - * @deprecated in favor of {@link ReactiveHttpRequestValues.Builder#addRequestPartPublisher}; - * to be removed in 6.2 - */ - @Deprecated(since = "6.1", forRemoval = true) - public > Builder addRequestPart(String name, P publisher, ResolvableType type) { - throw new UnsupportedOperationException(); - } - /** * Configure an attribute to associate with the request. * @param name the attribute name @@ -412,18 +389,34 @@ public void setBodyValue(@Nullable Object bodyValue) { this.bodyValue = bodyValue; } - /** - * Set the request body as a Reactive Streams Publisher. - *

        This is mutually exclusive with, and resets any previously set - * {@linkplain #setBodyValue(Object) body value}. - * @deprecated in favor of {@link ReactiveHttpRequestValues.Builder#setBodyPublisher}; - * to be removed in 6.2 - */ - @Deprecated(since = "6.1", forRemoval = true) - public > void setBody(P body, ParameterizedTypeReference elementTye) { - throw new UnsupportedOperationException(); + + // Implementation of {@link Metadata} methods + + @Override + @Nullable + public HttpMethod getHttpMethod() { + return this.httpMethod; + } + + @Override + @Nullable + public String getUriTemplate() { + return this.uriTemplate; + } + + @Override + @Nullable + public MediaType getContentType() { + return (this.headers != null ? this.headers.getContentType() : null); } + @Override + @Nullable + public List getAcceptMediaTypes() { + return (this.headers != null ? this.headers.getAccept() : null); + } + + /** * Build the {@link HttpRequestValues} instance. */ @@ -499,38 +492,18 @@ private String appendQueryParams( String uriTemplate, Map uriVars, MultiValueMap requestParams) { UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(uriTemplate); - int i = 0; for (Map.Entry> entry : requestParams.entrySet()) { - String nameVar = "queryParam" + i; + String nameVar = entry.getKey().replace(":", "%3A"); // suppress treatment as regex uriVars.put(nameVar, entry.getKey()); for (int j = 0; j < entry.getValue().size(); j++) { String valueVar = nameVar + "[" + j + "]"; uriVars.put(valueVar, entry.getValue().get(j)); uriComponentsBuilder.queryParam("{" + nameVar + "}", "{" + valueVar + "}"); } - i++; } return uriComponentsBuilder.build().toUriString(); } - /** - * Create {@link HttpRequestValues} from values passed to the {@link Builder}. - * @deprecated in favour of - * {@link Builder#createRequestValues(HttpMethod, URI, UriBuilderFactory, String, Map, HttpHeaders, MultiValueMap, Map, Object)} - * to be removed in 6.2. - */ - @Deprecated(since = "6.1", forRemoval = true) - protected HttpRequestValues createRequestValues( - @Nullable HttpMethod httpMethod, - @Nullable URI uri, @Nullable String uriTemplate, - Map uriVars, - HttpHeaders headers, MultiValueMap cookies, Map attributes, - @Nullable Object bodyValue) { - - return createRequestValues(httpMethod, uri, null, uriTemplate, - uriVars, headers, cookies, attributes, bodyValue); - } - /** * Create {@link HttpRequestValues} from values passed to the {@link Builder}. * @since 6.1 diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java index ca63327d242d..eaed346dd61b 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java @@ -19,6 +19,7 @@ import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.function.Function; @@ -46,6 +47,8 @@ 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; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; import org.springframework.util.StringValueResolver; @@ -156,6 +159,7 @@ private void applyArguments(HttpRequestValues.Builder requestValues, Object[] ar private record HttpRequestValuesInitializer( @Nullable HttpMethod httpMethod, @Nullable String url, @Nullable MediaType contentType, @Nullable List acceptMediaTypes, + MultiValueMap headers, Supplier requestValuesSupplier) { public HttpRequestValues.Builder initializeRequestValuesBuilder() { @@ -172,6 +176,8 @@ public HttpRequestValues.Builder initializeRequestValuesBuilder() { if (this.acceptMediaTypes != null) { requestValues.setAccept(this.acceptMediaTypes); } + this.headers.forEach((name, values) -> + values.forEach(value -> requestValues.addHeader(name, value))); return requestValues; } @@ -202,9 +208,10 @@ public static HttpRequestValuesInitializer create( String url = initUrl(typeAnnotation, methodAnnotation, embeddedValueResolver); MediaType contentType = initContentType(typeAnnotation, methodAnnotation); List acceptableMediaTypes = initAccept(typeAnnotation, methodAnnotation); + MultiValueMap headers = initHeaders(typeAnnotation, methodAnnotation, embeddedValueResolver); return new HttpRequestValuesInitializer( - httpMethod, url, contentType, acceptableMediaTypes, requestValuesSupplier); + httpMethod, url, contentType, acceptableMediaTypes, headers, requestValuesSupplier); } @Nullable @@ -223,6 +230,7 @@ private static HttpMethod initHttpMethod(@Nullable HttpExchange typeAnnotation, } @Nullable + @SuppressWarnings("NullAway") private static String initUrl( @Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation, @Nullable StringValueResolver embeddedValueResolver) { @@ -279,6 +287,44 @@ private static List initAccept(@Nullable HttpExchange typeAnnotation, return null; } + private static MultiValueMap initHeaders( + @Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation, + @Nullable StringValueResolver embeddedValueResolver) { + + MultiValueMap headers = new LinkedMultiValueMap<>(); + if (typeAnnotation != null) { + addHeaders(typeAnnotation.headers(), embeddedValueResolver, headers); + } + addHeaders(methodAnnotation.headers(), embeddedValueResolver, headers); + return headers; + } + + private static void addHeaders( + String[] rawValues, @Nullable StringValueResolver embeddedValueResolver, + MultiValueMap outputHeaders) { + + for (String rawValue: rawValues) { + String[] pair = StringUtils.split(rawValue, "="); + if (pair == null) { + continue; + } + String name = pair[0].trim(); + List values = new ArrayList<>(); + for (String value : StringUtils.commaDelimitedListToSet(pair[1])) { + if (embeddedValueResolver != null) { + value = embeddedValueResolver.resolveStringValue(value); + } + if (value != null) { + value = value.trim(); + values.add(value); + } + } + if (!values.isEmpty()) { + outputHeaders.addAll(name, values); + } + } + } + private static List getAnnotationDescriptors(AnnotatedElement element) { return MergedAnnotations.from(element, SearchStrategy.TYPE_HIERARCHY, RepeatableContainers.none()) .stream(HttpExchange.class) @@ -488,7 +534,9 @@ private static Function> initResponseEntityFunct return request -> client.exchangeForEntityFlux(request, bodyType) .map(entity -> { - Object body = reactiveAdapter.fromPublisher(entity.getBody()); + Flux entityBody = entity.getBody(); + Assert.state(entityBody != null, "Entity body must not be null"); + Object body = reactiveAdapter.fromPublisher(entityBody); return new ResponseEntity<>(body, entity.getHeaders(), entity.getStatusCode()); }); } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java index 5a0e6f7b89ff..863377344b16 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.lang.reflect.InvocationHandler; import java.lang.reflect.Method; -import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -32,7 +31,6 @@ import org.springframework.aop.framework.ReflectiveMethodInvocation; import org.springframework.core.KotlinDetector; import org.springframework.core.MethodIntrospector; -import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.convert.ConversionService; import org.springframework.format.support.DefaultFormattingConversionService; @@ -112,17 +110,6 @@ public static Builder builderFor(HttpExchangeAdapter exchangeAdapter) { return new Builder().exchangeAdapter(exchangeAdapter); } - /** - * Return a builder that's initialized with the given client. - * @deprecated in favor of {@link #builderFor(HttpExchangeAdapter)}; - * to be removed in 6.2. - */ - @SuppressWarnings("removal") - @Deprecated(since = "6.1", forRemoval = true) - public static Builder builder(HttpClientAdapter clientAdapter) { - return new Builder().exchangeAdapter(clientAdapter.asReactorExchangeAdapter()); - } - /** * Return an empty builder, with the client to be provided to builder. */ @@ -161,20 +148,6 @@ public Builder exchangeAdapter(HttpExchangeAdapter adapter) { return this; } - /** - * Provide the HTTP client to perform requests through. - * @param clientAdapter a client adapted to {@link HttpClientAdapter} - * @return this same builder instance - * @deprecated in favor of {@link #exchangeAdapter(HttpExchangeAdapter)}; - * to be removed in 6.2 - */ - @SuppressWarnings("removal") - @Deprecated(since = "6.1", forRemoval = true) - public Builder clientAdapter(HttpClientAdapter clientAdapter) { - this.exchangeAdapter = clientAdapter.asReactorExchangeAdapter(); - return this; - } - /** * Register a custom argument resolver, invoked ahead of default resolvers. * @param resolver the resolver to add @@ -207,40 +180,6 @@ public Builder embeddedValueResolver(StringValueResolver embeddedValueResolver) return this; } - /** - * Set the {@link ReactiveAdapterRegistry} to use to support different - * asynchronous types for HTTP service method return values. - *

        By default this is {@link ReactiveAdapterRegistry#getSharedInstance()}. - * @return this same builder instance - * @deprecated in favor of setting the same directly on the {@link HttpExchangeAdapter} - */ - @Deprecated(since = "6.1", forRemoval = true) - public Builder reactiveAdapterRegistry(ReactiveAdapterRegistry registry) { - if (this.exchangeAdapter instanceof AbstractReactorHttpExchangeAdapter settable) { - settable.setReactiveAdapterRegistry(registry); - } - return this; - } - - /** - * Configure how long to block for the response of an HTTP service method - * with a synchronous (blocking) method signature. - *

        By default this is not set, in which case the behavior depends on - * connection and request timeout settings of the underlying HTTP client. - * We recommend configuring timeout values directly on the underlying HTTP - * client, which provides more control over such settings. - * @param blockTimeout the timeout value - * @return this same builder instance - * @deprecated in favor of setting the same directly on the {@link HttpExchangeAdapter} - */ - @Deprecated(since = "6.1", forRemoval = true) - public Builder blockTimeout(@Nullable Duration blockTimeout) { - if (this.exchangeAdapter instanceof AbstractReactorHttpExchangeAdapter settable) { - settable.setBlockTimeout(blockTimeout); - } - return this; - } - /** * Build the {@link HttpServiceProxyFactory} instance. */ @@ -251,7 +190,7 @@ public HttpServiceProxyFactory build() { this.exchangeAdapter, initArgumentResolvers(), this.embeddedValueResolver); } - @SuppressWarnings("DataFlowIssue") + @SuppressWarnings({"DataFlowIssue", "NullAway"}) private List initArgumentResolvers() { // Custom diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/ReactiveHttpRequestValues.java b/spring-web/src/main/java/org/springframework/web/service/invoker/ReactiveHttpRequestValues.java index daf33cb0bf9e..de5d477f2e96 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/ReactiveHttpRequestValues.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/ReactiveHttpRequestValues.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,6 @@ import org.reactivestreams.Publisher; import org.springframework.core.ParameterizedTypeReference; -import org.springframework.core.ResolvableType; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; @@ -63,7 +62,7 @@ private ReactiveHttpRequestValues( /** - * Return a {@link Publisher} that will produce for the request body. + * Return a {@link Publisher} that will produce the request body. *

        This is mutually exclusive with {@link #getBodyValue()}. * Only one of the two or neither is set. */ @@ -73,33 +72,13 @@ public Publisher getBodyPublisher() { } /** - * Return the element type for a {@linkplain #getBodyPublisher() Publisher body}. + * Return the element type for a {@linkplain #getBodyPublisher() body publisher}. */ @Nullable public ParameterizedTypeReference getBodyPublisherElementType() { return this.bodyElementType; } - /** - * Return the request body as a Publisher. - *

        This is mutually exclusive with {@link #getBodyValue()}. - * Only one of the two or neither is set. - */ - @SuppressWarnings("removal") - @Nullable - public Publisher getBody() { - return getBodyPublisher(); - } - - /** - * Return the element type for a {@linkplain #getBodyPublisher() Publisher body}. - */ - @SuppressWarnings("removal") - @Nullable - public ParameterizedTypeReference getBodyElementType() { - return getBodyPublisherElementType(); - } - public static Builder builder() { return new Builder(); @@ -209,16 +188,11 @@ public > Builder addRequestPartPublisher( return this; } - @SuppressWarnings("removal") - @Override - public > Builder addRequestPart(String name, P publisher, ResolvableType type) { - return addRequestPartPublisher(name, publisher, ParameterizedTypeReference.forType(type.getType())); - } - /** * {@inheritDoc} - *

        This is mutually exclusive with, and resets any previously set - * {@linkplain #setBodyPublisher(Publisher, ParameterizedTypeReference)}. + *

        This is mutually exclusive with and resets any previously set + * {@linkplain #setBodyPublisher(Publisher, ParameterizedTypeReference) + * body publisher}. */ @Override public void setBodyValue(@Nullable Object bodyValue) { @@ -228,8 +202,8 @@ public void setBodyValue(@Nullable Object bodyValue) { } /** - * Set the request body as a Reactive Streams Publisher. - *

        This is mutually exclusive with, and resets any previously set + * Set the request body as a Reactive Streams {@link Publisher}. + *

        This is mutually exclusive with and resets any previously set * {@linkplain #setBodyValue(Object) body value}. */ @SuppressWarnings("DataFlowIssue") @@ -239,12 +213,6 @@ public > void setBodyPublisher(P body, ParameterizedTy super.setBodyValue(null); } - @SuppressWarnings("removal") - @Override - public > void setBody(P body, ParameterizedTypeReference elementTye) { - setBodyPublisher(body, elementTye); - } - @Override public ReactiveHttpRequestValues build() { return (ReactiveHttpRequestValues) super.build(); diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestBodyArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestBodyArgumentResolver.java index cdd3c6f0c05f..a84ef532acde 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestBodyArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestBodyArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.web.service.invoker; +import java.util.Optional; + import org.springframework.core.MethodParameter; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ReactiveAdapter; @@ -30,6 +32,7 @@ * annotated arguments. * * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma * @since 6.0 */ public class RequestBodyArgumentResolver implements HttpServiceArgumentResolver { @@ -68,33 +71,39 @@ public boolean resolve( return false; } - if (argument != null) { - if (this.reactiveAdapterRegistry != null) { - ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(parameter.getParameterType()); - if (adapter != null) { - MethodParameter nestedParameter = parameter.nested(); - - String message = "Async type for @RequestBody should produce value(s)"; - Assert.isTrue(!adapter.isNoValue(), message); - Assert.isTrue(nestedParameter.getNestedParameterType() != Void.class, message); - - if (requestValues instanceof ReactiveHttpRequestValues.Builder reactiveRequestValues) { - reactiveRequestValues.setBodyPublisher( - adapter.toPublisher(argument), asParameterizedTypeRef(nestedParameter)); - } - else { - throw new IllegalStateException( - "RequestBody with a reactive type is only supported with reactive client"); - } - - return true; + if (argument instanceof Optional optionalValue) { + argument = optionalValue.orElse(null); + } + + if (argument == null) { + Assert.isTrue(!annot.required() || parameter.isOptional(), "RequestBody is required"); + return true; + } + + if (this.reactiveAdapterRegistry != null) { + ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(parameter.getParameterType()); + if (adapter != null) { + MethodParameter nestedParameter = parameter.nested(); + + String message = "Async type for @RequestBody should produce value(s)"; + Assert.isTrue(!adapter.isNoValue(), message); + Assert.isTrue(nestedParameter.getNestedParameterType() != Void.class, message); + + if (requestValues instanceof ReactiveHttpRequestValues.Builder reactiveRequestValues) { + reactiveRequestValues.setBodyPublisher( + adapter.toPublisher(argument), asParameterizedTypeRef(nestedParameter)); + } + else { + throw new IllegalStateException( + "RequestBody with a reactive type is only supported with reactive client"); } - } - // Not a reactive type - requestValues.setBodyValue(argument); + return true; + } } + // Not a reactive type + requestValues.setBodyValue(argument); return true; } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestParamArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestParamArgumentResolver.java index ca72b532bcac..4bcf4aef779e 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestParamArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestParamArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,6 +18,7 @@ import org.springframework.core.MethodParameter; import org.springframework.core.convert.ConversionService; +import org.springframework.http.MediaType; import org.springframework.lang.Nullable; import org.springframework.web.bind.annotation.RequestParam; @@ -54,18 +55,72 @@ */ public class RequestParamArgumentResolver extends AbstractNamedValueArgumentResolver { + private boolean favorSingleValue; + public RequestParamArgumentResolver(ConversionService conversionService) { super(conversionService); } + /** + * Whether to format multiple values (for example, collection, array) as a single + * String value through the configured {@link ConversionService} unless the + * content type is form data, or it is a multipart request. + *

        By default, this is {@code false} in which case formatting is not applied, + * and a separate parameter with the same name is created for each value. + * @since 6.2 + */ + public void setFavorSingleValue(boolean favorSingleValue) { + this.favorSingleValue = favorSingleValue; + } + + /** + * Return the setting for {@link #setFavorSingleValue favorSingleValue}. + * @since 6.2 + */ + public boolean isFavorSingleValue() { + return this.favorSingleValue; + } + + @Override @Nullable - protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter, HttpRequestValues.Metadata metadata) { RequestParam annot = parameter.getParameterAnnotation(RequestParam.class); - return (annot == null ? null : - new NamedValueInfo(annot.name(), annot.required(), annot.defaultValue(), "request parameter", true)); + if (annot == null) { + return null; + } + return new NamedValueInfo( + annot.name(), annot.required(), annot.defaultValue(), "request parameter", + supportsMultipleValues(parameter, metadata)); + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + // Shouldn't be called since we override createNamedValueInfo with HttpRequestValues.Metadata + throw new UnsupportedOperationException(); + } + + /** + * Determine whether the resolver should send multi-value request parameters + * as individual values. If not, they are formatted to a single String value. + * The default implementation uses {@link #isFavorSingleValue()} to decide + * unless the content type is form data, or it is a multipart request. + * @since 6.2 + */ + protected boolean supportsMultipleValues(MethodParameter parameter, HttpRequestValues.Metadata metadata) { + return (!isFavorSingleValue() || isFormOrMultipartContent(metadata)); + } + + /** + * Whether the content type is form data, or it is a multipart request. + * @since 6.2 + */ + protected boolean isFormOrMultipartContent(HttpRequestValues.Metadata metadata) { + MediaType mediaType = metadata.getContentType(); + return (mediaType != null && (mediaType.getType().equals("multipart") || + mediaType.isCompatibleWith(MediaType.APPLICATION_FORM_URLENCODED))); } @Override diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestPartArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestPartArgumentResolver.java index 77180350d693..f4173d6e0666 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/RequestPartArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/RequestPartArgumentResolver.java @@ -40,7 +40,7 @@ *

      • String -- form field *
      • {@link org.springframework.core.io.Resource Resource} -- file part *
      • {@link MultipartFile} -- uploaded file - *
      • Object -- content to be encoded (e.g. to JSON) + *
      • Object -- content to be encoded (for example, to JSON) *
      • {@link HttpEntity} -- part content and headers although generally it's * easier to add headers through the returned builder *
      • {@link Part} -- a part from a server request diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolver.java index d5d40f0bad78..0c89f905e593 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +17,11 @@ package org.springframework.web.service.invoker; import java.net.URL; +import java.util.Optional; import org.springframework.core.MethodParameter; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.web.util.UriBuilderFactory; import org.springframework.web.util.UriTemplate; @@ -42,14 +44,22 @@ public class UriBuilderFactoryArgumentResolver implements HttpServiceArgumentRes public boolean resolve( @Nullable Object argument, MethodParameter parameter, HttpRequestValues.Builder requestValues) { - if (!parameter.getParameterType().equals(UriBuilderFactory.class)) { + parameter = parameter.nestedIfOptional(); + + if (!parameter.getNestedParameterType().equals(UriBuilderFactory.class)) { return false; } - if (argument != null) { - requestValues.setUriBuilderFactory((UriBuilderFactory) argument); + if (argument instanceof Optional optionalValue) { + argument = optionalValue.orElse(null); + } + + if (argument == null) { + Assert.isTrue(parameter.isOptional(), "UriBuilderFactory is required"); + return true; } + requestValues.setUriBuilderFactory((UriBuilderFactory) argument); return true; } } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/UrlArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/UrlArgumentResolver.java index 3ca43c85051e..e3840a55347c 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/UrlArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/UrlArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,15 +17,18 @@ package org.springframework.web.service.invoker; import java.net.URI; +import java.util.Optional; import org.springframework.core.MethodParameter; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; /** * {@link HttpServiceArgumentResolver} that resolves the URL for the request * from a {@link URI} argument. * * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma * @since 6.0 */ public class UrlArgumentResolver implements HttpServiceArgumentResolver { @@ -34,14 +37,22 @@ public class UrlArgumentResolver implements HttpServiceArgumentResolver { public boolean resolve( @Nullable Object argument, MethodParameter parameter, HttpRequestValues.Builder requestValues) { - if (!parameter.getParameterType().equals(URI.class)) { + parameter = parameter.nestedIfOptional(); + + if (!parameter.getNestedParameterType().equals(URI.class)) { return false; } - if (argument != null) { - requestValues.setUri((URI) argument); + if (argument instanceof Optional optionalValue) { + argument = optionalValue.orElse(null); + } + + if (argument == null) { + Assert.isTrue(parameter.isOptional(), "URI is required"); + return true; } + requestValues.setUri((URI) argument); return true; } diff --git a/spring-web/src/main/java/org/springframework/web/util/ContentCachingRequestWrapper.java b/spring-web/src/main/java/org/springframework/web/util/ContentCachingRequestWrapper.java index 3821051fa7b7..c98ab1b9ac89 100644 --- a/spring-web/src/main/java/org/springframework/web/util/ContentCachingRequestWrapper.java +++ b/spring-web/src/main/java/org/springframework/web/util/ContentCachingRequestWrapper.java @@ -47,7 +47,7 @@ * content is not consumed, then the content is not cached, and cannot be * retrieved via {@link #getContentAsByteArray()}. * - *

        Used e.g. by {@link org.springframework.web.filter.AbstractRequestLoggingFilter}. + *

        Used, for example, by {@link org.springframework.web.filter.AbstractRequestLoggingFilter}. * * @author Juergen Hoeller * @author Brian Clozel diff --git a/spring-web/src/main/java/org/springframework/web/util/ContentCachingResponseWrapper.java b/spring-web/src/main/java/org/springframework/web/util/ContentCachingResponseWrapper.java index 41fc196d6781..6f150b7547d1 100644 --- a/spring-web/src/main/java/org/springframework/web/util/ContentCachingResponseWrapper.java +++ b/spring-web/src/main/java/org/springframework/web/util/ContentCachingResponseWrapper.java @@ -40,7 +40,7 @@ * the {@linkplain #getOutputStream() output stream} and {@linkplain #getWriter() writer}, * and allows this content to be retrieved via a {@linkplain #getContentAsByteArray() byte array}. * - *

        Used e.g. by {@link org.springframework.web.filter.ShallowEtagHeaderFilter}. + *

        Used, for example, by {@link org.springframework.web.filter.ShallowEtagHeaderFilter}. * * @author Juergen Hoeller * @author Sam Brannen diff --git a/spring-web/src/main/java/org/springframework/web/util/DefaultUriBuilderFactory.java b/spring-web/src/main/java/org/springframework/web/util/DefaultUriBuilderFactory.java index c90c49470e45..96639045e61f 100644 --- a/spring-web/src/main/java/org/springframework/web/util/DefaultUriBuilderFactory.java +++ b/spring-web/src/main/java/org/springframework/web/util/DefaultUriBuilderFactory.java @@ -45,6 +45,9 @@ public class DefaultUriBuilderFactory implements UriBuilderFactory { @Nullable private final UriComponentsBuilder baseUri; + @Nullable + private UriComponentsBuilder.ParserType parserType; + private EncodingMode encodingMode = EncodingMode.TEMPLATE_AND_VALUES; @Nullable @@ -92,6 +95,28 @@ public final boolean hasBaseUri() { return (this.baseUri != null); } + /** + * Set the {@link UriComponentsBuilder.ParserType} to use. + *

        By default, {@link UriComponentsBuilder} uses the + * {@link UriComponentsBuilder.ParserType#RFC parser type}. + * @param parserType the parser type + * @since 6.2 + * @see UriComponentsBuilder.ParserType + * @see UriComponentsBuilder#fromUriString(String, UriComponentsBuilder.ParserType) + */ + public void setParserType(UriComponentsBuilder.ParserType parserType) { + this.parserType = parserType; + } + + /** + * Return the configured parser type. + * @since 6.2 + */ + @Nullable + public UriComponentsBuilder.ParserType getParserType() { + return this.parserType; + } + /** * Set the {@link EncodingMode encoding mode} to use. *

        By default this is set to {@link EncodingMode#TEMPLATE_AND_VALUES @@ -265,12 +290,12 @@ private UriComponentsBuilder initUriComponentsBuilder(String uriTemplate) { result = (baseUri != null ? baseUri.cloneBuilder() : UriComponentsBuilder.newInstance()); } else if (baseUri != null) { - UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(uriTemplate); + UriComponentsBuilder builder = parseUri(uriTemplate); UriComponents uri = builder.build(); result = (uri.getHost() == null ? baseUri.cloneBuilder().uriComponents(uri) : builder); } else { - result = UriComponentsBuilder.fromUriString(uriTemplate); + result = parseUri(uriTemplate); } if (encodingMode.equals(EncodingMode.TEMPLATE_AND_VALUES)) { result.encode(); @@ -279,6 +304,12 @@ else if (baseUri != null) { return result; } + private UriComponentsBuilder parseUri(String uriTemplate) { + return (getParserType() != null ? + UriComponentsBuilder.fromUriString(uriTemplate, getParserType()) : + UriComponentsBuilder.fromUriString(uriTemplate)); + } + private void parsePathIfNecessary(UriComponentsBuilder result) { if (parsePath && encodingMode.equals(EncodingMode.URI_COMPONENT)) { UriComponents uric = result.build(); diff --git a/spring-web/src/main/java/org/springframework/web/util/DisconnectedClientHelper.java b/spring-web/src/main/java/org/springframework/web/util/DisconnectedClientHelper.java index b2b16f135adb..a62f6312bbb5 100644 --- a/spring-web/src/main/java/org/springframework/web/util/DisconnectedClientHelper.java +++ b/spring-web/src/main/java/org/springframework/web/util/DisconnectedClientHelper.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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.web.util; +import java.util.HashSet; import java.util.Locale; import java.util.Set; @@ -24,12 +25,14 @@ import org.springframework.core.NestedExceptionUtils; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; /** - * Utility methods to assist with identifying and logging exceptions that indicate - * the client has gone away. Such exceptions fill logs with unnecessary stack - * traces. The utility methods help to log a single line message at DEBUG level, - * and a full stacktrace at TRACE level. + * Utility methods to assist with identifying and logging exceptions that + * indicate the server response connection is lost, for example because the + * client has gone away. This class helps to identify such exceptions and + * minimize logging to a single line at DEBUG level, while making the full + * error stacktrace at TRACE level. * * @author Rossen Stoyanchev * @since 6.1 @@ -37,12 +40,28 @@ public class DisconnectedClientHelper { private static final Set EXCEPTION_PHRASES = - Set.of("broken pipe", "connection reset"); + Set.of("broken pipe", "connection reset by peer"); private static final Set EXCEPTION_TYPE_NAMES = Set.of("AbortedException", "ClientAbortException", "EOFException", "EofException", "AsyncRequestNotUsableException"); + private static final Set> CLIENT_EXCEPTION_TYPES = new HashSet<>(2); + + static { + try { + ClassLoader classLoader = DisconnectedClientHelper.class.getClassLoader(); + CLIENT_EXCEPTION_TYPES.add(ClassUtils.forName( + "org.springframework.web.client.RestClientException", classLoader)); + CLIENT_EXCEPTION_TYPES.add(ClassUtils.forName( + "org.springframework.web.reactive.function.client.WebClientException", classLoader)); + } + catch (ClassNotFoundException ex) { + // ignore + } + } + + private final Log logger; @@ -79,10 +98,25 @@ else if (logger.isDebugEnabled()) { *

      • ClientAbortException or EOFException for Tomcat *
      • EofException for Jetty *
      • IOException "Broken pipe" or "connection reset by peer" - *
      • SocketException "Connection reset" * */ public static boolean isClientDisconnectedException(Throwable ex) { + Throwable currentEx = ex; + Throwable lastEx = null; + while (currentEx != null && currentEx != lastEx) { + // Ignore onward connection issues to other servers (500 error) + for (Class exceptionType : CLIENT_EXCEPTION_TYPES) { + if (exceptionType.isInstance(currentEx)) { + return false; + } + } + if (EXCEPTION_TYPE_NAMES.contains(currentEx.getClass().getSimpleName())) { + return true; + } + lastEx = currentEx; + currentEx = currentEx.getCause(); + } + String message = NestedExceptionUtils.getMostSpecificCause(ex).getMessage(); if (message != null) { String text = message.toLowerCase(Locale.ROOT); @@ -92,7 +126,8 @@ public static boolean isClientDisconnectedException(Throwable ex) { } } } - return EXCEPTION_TYPE_NAMES.contains(ex.getClass().getSimpleName()); + + return false; } } diff --git a/spring-web/src/main/java/org/springframework/web/util/HierarchicalUriComponents.java b/spring-web/src/main/java/org/springframework/web/util/HierarchicalUriComponents.java index c97f84e6985a..3af171ebc967 100644 --- a/spring-web/src/main/java/org/springframework/web/util/HierarchicalUriComponents.java +++ b/spring-web/src/main/java/org/springframework/web/util/HierarchicalUriComponents.java @@ -590,25 +590,25 @@ public boolean isAllowed(int c) { AUTHORITY { @Override public boolean isAllowed(int c) { - return isUnreserved(c) || isSubDelimiter(c) || ':' == c || '@' == c; + return (isUnreservedOrSubDelimiter(c) || ':' == c || '@' == c); } }, USER_INFO { @Override public boolean isAllowed(int c) { - return isUnreserved(c) || isSubDelimiter(c) || ':' == c; + return (isUnreservedOrSubDelimiter(c) || ':' == c); } }, HOST_IPV4 { @Override public boolean isAllowed(int c) { - return isUnreserved(c) || isSubDelimiter(c); + return isUnreservedOrSubDelimiter(c); } }, HOST_IPV6 { @Override public boolean isAllowed(int c) { - return isUnreserved(c) || isSubDelimiter(c) || '[' == c || ']' == c || ':' == c; + return (isUnreservedOrSubDelimiter(c) || '[' == c || ']' == c || ':' == c); } }, PORT { @@ -620,7 +620,7 @@ public boolean isAllowed(int c) { PATH { @Override public boolean isAllowed(int c) { - return isPchar(c) || '/' == c; + return (isPchar(c) || '/' == c); } }, PATH_SEGMENT { @@ -632,7 +632,7 @@ public boolean isAllowed(int c) { QUERY { @Override public boolean isAllowed(int c) { - return isPchar(c) || '/' == c || '?' == c; + return (isPchar(c) || '/' == c || '?' == c); } }, QUERY_PARAM { @@ -642,14 +642,14 @@ public boolean isAllowed(int c) { return false; } else { - return isPchar(c) || '/' == c || '?' == c; + return (isPchar(c) || '/' == c || '?' == c); } } }, FRAGMENT { @Override public boolean isAllowed(int c) { - return isPchar(c) || '/' == c || '?' == c; + return (isPchar(c) || '/' == c || '?' == c); } }, URI { @@ -659,6 +659,15 @@ public boolean isAllowed(int c) { } }; + private static final boolean[] unreservedOrSubDelimiterArray = new boolean[128]; + + static { + for (int i = 0; i < 128; i++) { + char c = (char) i; + unreservedOrSubDelimiterArray[i] = (URI.isUnreserved(c) || URI.isSubDelimiter(c)); + } + } + /** * Indicates whether the given character is allowed in this URI component. * @return {@code true} if the character is allowed; {@code false} otherwise @@ -694,8 +703,8 @@ protected boolean isGenericDelimiter(int c) { * @see RFC 3986, appendix A */ protected boolean isSubDelimiter(int c) { - return ('!' == c || '$' == c || '&' == c || '\'' == c || '(' == c || ')' == c || '*' == c || '+' == c || - ',' == c || ';' == c || '=' == c); + return ('!' == c || '$' == c || '&' == c || '\'' == c || + '(' == c || ')' == c || '*' == c || '+' == c || ',' == c || ';' == c || '=' == c); } /** @@ -719,8 +728,16 @@ protected boolean isUnreserved(int c) { * @see RFC 3986, appendix A */ protected boolean isPchar(int c) { - return (isUnreserved(c) || isSubDelimiter(c) || ':' == c || '@' == c); + return (isUnreservedOrSubDelimiter(c) || ':' == c || '@' == c); } + + /** + * Combined check whether a character is unreserved or a sub-delimiter. + */ + protected boolean isUnreservedOrSubDelimiter(int c) { + return (c < unreservedOrSubDelimiterArray.length && c >= 0 && unreservedOrSubDelimiterArray[c]); + } + } @@ -822,7 +839,7 @@ else if (level > 0) { * Whether the given String is a single URI variable that can be * expanded. It must have '{' and '}' surrounding non-empty text and no * nested placeholders unless it is a variable with regex syntax, - * e.g. {@code "/{year:\d{1,4}}"}. + * for example, {@code "/{year:\d{1,4}}"}. */ private boolean isUriVariable(CharSequence source) { if (source.length() < 2 || source.charAt(0) != '{' || source.charAt(source.length() -1) != '}') { diff --git a/spring-web/src/main/java/org/springframework/web/util/HtmlUtils.java b/spring-web/src/main/java/org/springframework/web/util/HtmlUtils.java index 07a200dc2011..ca57017a7fdd 100644 --- a/spring-web/src/main/java/org/springframework/web/util/HtmlUtils.java +++ b/spring-web/src/main/java/org/springframework/web/util/HtmlUtils.java @@ -51,7 +51,7 @@ public abstract class HtmlUtils { * Turn special characters into HTML character references. *

        Handles the complete character set defined in the HTML 4.01 recommendation. *

        Escapes all special characters to their corresponding - * entity reference (e.g. {@code <}). + * entity reference (for example, {@code <}). *

        Reference: * * https://www.w3.org/TR/html4/sgml/entities.html @@ -67,7 +67,7 @@ public static String htmlEscape(String input) { * Turn special characters into HTML character references. *

        Handles the complete character set defined in the HTML 4.01 recommendation. *

        Escapes all special characters to their corresponding - * entity reference (e.g. {@code <}) at least as required by the + * entity reference (for example, {@code <}) at least as required by the * specified encoding. In other words, if a special character does * not have to be escaped for the given encoding, it may not be. *

        Reference: diff --git a/spring-web/src/main/java/org/springframework/web/util/IntrospectorCleanupListener.java b/spring-web/src/main/java/org/springframework/web/util/IntrospectorCleanupListener.java index cdf38031364a..8345c1bbe1df 100644 --- a/spring-web/src/main/java/org/springframework/web/util/IntrospectorCleanupListener.java +++ b/spring-web/src/main/java/org/springframework/web/util/IntrospectorCleanupListener.java @@ -52,7 +52,7 @@ *

        Application classes hardly ever need to use the JavaBeans Introspector * directly, so are normally not the cause of Introspector resource leaks. * Rather, many libraries and frameworks do not clean up the Introspector: - * e.g. Struts and Quartz. + * for example, Struts and Quartz. * *

        Note that a single such Introspector leak will cause the entire web * app class loader to not get garbage collected! This has the consequence that diff --git a/spring-web/src/main/java/org/springframework/web/util/InvalidUrlException.java b/spring-web/src/main/java/org/springframework/web/util/InvalidUrlException.java new file mode 100644 index 000000000000..100b56df1bbb --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/InvalidUrlException.java @@ -0,0 +1,47 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.util; + +/** + * Thrown when a URL string cannot be parsed. + * + * @author Arjen Poutsma + * @since 6.2 + */ +public class InvalidUrlException extends IllegalArgumentException { + + private static final long serialVersionUID = 7409308391039105562L; + + + /** + * Construct a {@code InvalidUrlException} with the specified detail message. + * @param msg the detail message + */ + public InvalidUrlException(String msg) { + super(msg); + } + + /** + * Construct a {@code InvalidUrlException} with the specified detail message + * and nested exception. + * @param msg the detail message + * @param cause the nested exception + */ + public InvalidUrlException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/spring-web/src/main/java/org/springframework/web/util/RfcUriParser.java b/spring-web/src/main/java/org/springframework/web/util/RfcUriParser.java new file mode 100644 index 000000000000..cf4e28481576 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/RfcUriParser.java @@ -0,0 +1,656 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.util; + +import java.util.Locale; +import java.util.Set; + +import org.apache.commons.logging.Log; + +import org.springframework.core.log.LogDelegateFactory; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Parser for URI's based on RFC 3986 syntax. + * + * @author Rossen Stoyanchev + * @since 6.2 + * + * @see RFC 3986 + */ +abstract class RfcUriParser { + + private static final Log logger = LogDelegateFactory.getHiddenLog(RfcUriParser.class); + + + /** + * Parse the given URI string. + * @param uri the input string to parse + * @return {@link UriRecord} with the parsed components + * @throws InvalidUrlException when the URI cannot be parsed, e.g. due to syntax errors + */ + public static UriRecord parse(String uri) { + return new InternalParser(uri).parse(); + } + + + private static void verify(boolean expression, InternalParser parser, String message) { + if (!expression) { + fail(parser, message); + } + } + + private static void verifyIsHexDigit(char c, InternalParser parser, String message) { + verify((c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || (c >= '0' && c <= '9'), parser, message); + } + + private static void fail(InternalParser parser, String message) { + if (logger.isTraceEnabled()) { + logger.trace(InvalidUrlException.class.getSimpleName() + ": \"" + message + "\" " + parser); + } + throw new InvalidUrlException(message); + } + + + /** + * Holds the parsed URI components. + * @param scheme the scheme, for an absolute URI, or {@code null} + * @param isOpaque if {@code true}, the path contains the remaining scheme-specific part + * @param user user information, if present in the authority + * @param host the host, if present in the authority + * @param port the port, if present in the authority + * @param path the path, if present + * @param query the query, if present + * @param fragment the fragment, if present + */ + record UriRecord(@Nullable String scheme, boolean isOpaque, + @Nullable String user, @Nullable String host, @Nullable String port, + @Nullable String path, @Nullable String query, @Nullable String fragment) { + + } + + + /** + * Parse states with handling for each character. + */ + private enum State { + + START { + + @Override + public void handleNext(InternalParser parser, char c, int i) { + switch (c) { + case '/': + parser.advanceTo(HOST_OR_PATH, i); + break; + case ';': + case '.': + parser.advanceTo(PATH, i); + break; + case '%': + parser.markPercentEncoding().advanceTo(PATH, i); + break; + case '?': + parser.advanceTo(QUERY, i + 1); // empty path + break; + case '#': + parser.advanceTo(FRAGMENT, i + 1); // empty path + break; + case '*': + parser.advanceTo(WILDCARD); + break; + default: + if (parser.hasScheme()) { + parser.resolveIfOpaque().advanceTo(PATH, i); + } + else { + parser.advanceTo(SCHEME_OR_PATH, i); + } + } + } + + @Override + public void handleEnd(InternalParser parser) { + parser.capturePath(); + } + }, + + HOST_OR_PATH { + + @Override + public void handleNext(InternalParser parser, char c, int i) { + switch (c) { + case '/': + parser.componentIndex(i).captureHost().advanceTo(HOST, i + 1); // empty host to start + break; + case '%': + case '@': + case ';': + case '?': + case '#': + case '.': + parser.index(--i); + parser.advanceTo(PATH); + break; + default: + parser.advanceTo(PATH); + } + } + + @Override + public void handleEnd(InternalParser parser) { + parser.capturePath(); + } + }, + + SCHEME_OR_PATH { + + @Override + public void handleNext(InternalParser parser, char c, int i) { + switch (c) { + case ':': + parser.captureScheme().advanceTo(START); + break; + case '/': + case ';': + parser.advanceTo(PATH); + break; + case '%': + parser.markPercentEncoding().advanceTo(PATH); + break; + case '?': + parser.capturePath().advanceTo(QUERY, i + 1); + break; + case '#': + parser.capturePath().advanceTo(FRAGMENT); + break; + } + } + + @Override + public void handleEnd(InternalParser parser) { + parser.capturePath(); + } + }, + + HOST { + + @Override + public void handleNext(InternalParser parser, char c, int i) { + switch (c) { + case '/': + parser.captureHost().advanceTo(PATH, i); + break; + case ':': + parser.captureHostIfNotEmpty().advanceTo(PORT, i + 1); + break; + case '?': + parser.captureHostIfNotEmpty().advanceTo(QUERY, i + 1); + break; + case '#': + parser.captureHostIfNotEmpty().advanceTo(FRAGMENT, i + 1); + break; + case '@': + parser.captureUser().componentIndex(i + 1); + break; + case '[': + verify(parser.isAtStartOfComponent(), parser, "Bad authority"); + parser.advanceTo(IPV6); + break; + case '%': + parser.markPercentEncoding(); + break; + default: + boolean isAllowed = (parser.processCurlyBrackets(c) || + parser.countDownPercentEncodingInHost(c) || + HierarchicalUriComponents.Type.URI.isUnreservedOrSubDelimiter(c)); + verify(isAllowed, parser, "Bad authority"); + } + } + + @Override + public void handleEnd(InternalParser parser) { + parser.captureHostIfNotEmpty(); + } + }, + + IPV6 { + + @Override + public void handleNext(InternalParser parser, char c, int i) { + switch (c) { + case ']': + parser.index(++i); + parser.captureHost(); + if (parser.hasNext()) { + if (parser.charAtIndex() == ':') { + parser.advanceTo(PORT, i + 1); + } + else { + parser.advanceTo(PATH, i); + } + } + break; + case ':': + break; + default: + verifyIsHexDigit(c, parser, "Bad authority"); + } + } + + @Override + public void handleEnd(InternalParser parser) { + verify(parser.hasHost(), parser, "Bad authority"); // no closing ']' + } + }, + + PORT { + + @Override + public void handleNext(InternalParser parser, char c, int i) { + if (c == '@') { + verify(!parser.hasUser(), parser, "Bad authority"); + parser.switchPortForFullPassword().advanceTo(HOST, i + 1); + } + else if (c == '/') { + parser.capturePort().advanceTo(PATH, i); + } + else if (c == '?' || c == '#') { + parser.capturePort().advanceTo((c == '?' ? QUERY : FRAGMENT), i + 1); + } + else if (!Character.isDigit(c)) { + if (parser.processCurlyBrackets(c)) { + return; + } + else if (HierarchicalUriComponents.Type.URI.isUnreservedOrSubDelimiter(c) || c == '%') { + parser.switchPortForPassword().advanceTo(HOST); + return; + } + fail(parser, "Bad authority"); + } + } + + @Override + public void handleEnd(InternalParser parser) { + parser.capturePort(); + } + }, + + PATH { + + @Override + public void handleNext(InternalParser parser, char c, int i) { + if (!parser.countDownPercentEncodingInPath(c)) { + switch (c) { + case '?': + if (parser.isOpaque()) { + break; + } + parser.capturePath().advanceTo(QUERY, i + 1); + break; + case '#': + parser.capturePath().advanceTo(FRAGMENT, i + 1); + break; + case '%': + parser.markPercentEncoding(); + break; + } + } + } + + @Override + public void handleEnd(InternalParser parser) { + parser.capturePath(); + } + }, + + QUERY { + + @Override + public void handleNext(InternalParser parser, char c, int i) { + if (c == '#') { + parser.captureQuery().advanceTo(FRAGMENT, i + 1); + } + } + + @Override + public void handleEnd(InternalParser parser) { + parser.captureQuery(); + } + }, + + FRAGMENT { + @Override + public void handleNext(InternalParser parser, char c, int i) { + } + + @Override + public void handleEnd(InternalParser parser) { + parser.captureFragmentIfNotEmpty(); + } + }, + + WILDCARD { + + @Override + public void handleNext(InternalParser parser, char c, int i) { + fail(parser, "Bad character '*'"); + } + + @Override + public void handleEnd(InternalParser parser) { + parser.capturePath(); + } + }; + + /** + * Method to handle each character from the input string. + * @param parser provides access to parsing state, and helper methods + * @param c the current character + * @param i the current index + */ + public abstract void handleNext(InternalParser parser, char c, int i); + + /** + * Finalize handling at the end of the input. + * @param parser provides access to parsing state, and helper methods + */ + public abstract void handleEnd(InternalParser parser); + + } + + + /** + * Delegates to {@link State}s for handling of character one by one, holds + * parsing state, and exposes helper methods. + */ + private static class InternalParser { + + private static final Set hierarchicalSchemes = Set.of("ftp", "file", "http", "https", "ws", "wss"); + + + private final String uri; + + @Nullable + private String scheme; + + @Nullable + String user; + + @Nullable + private String host; + + @Nullable + private String path; + + @Nullable + String port; + + @Nullable + String query; + + @Nullable + String fragment; + + + private State state = State.START; + + private int index; + + private int componentIndex; + + boolean isOpaque; + + private int remainingPercentEncodedChars; + + private boolean inUtf16Sequence; + + private boolean inPassword; + + private int openCurlyBracketCount; + + + public InternalParser(String uri) { + this.uri = uri; + } + + // Check internal state + + public boolean hasScheme() { + return (this.scheme != null); + } + + public boolean isOpaque() { + return this.isOpaque; + } + + public boolean hasUser() { + return (this.user != null); + } + + public boolean hasHost() { + return (this.host != null); + } + + public boolean isAtStartOfComponent() { + return (this.index == this.componentIndex); + } + + // Top-level parse loop, iterate over chars and delegate to states + + public UriRecord parse() { + Assert.isTrue(this.state == State.START && this.index == 0, "Internal Error"); + + while (hasNext()) { + this.state.handleNext(this, charAtIndex(), this.index); + this.index++; + } + + this.state.handleEnd(this); + + return new UriRecord(this.scheme, this.isOpaque, + this.user, this.host, this.port, this.path, this.query, this.fragment); + } + + public boolean hasNext() { + return (this.index < this.uri.length()); + } + + public char charAtIndex() { + return this.uri.charAt(this.index); + } + + // Transitions and index updates + + public void advanceTo(State state) { + if (logger.isTraceEnabled()) { + logger.trace(this.state + " -> " + state + ", " + + "index=" + this.index + ", componentIndex=" + this.componentIndex); + } + this.state = state; + this.openCurlyBracketCount = 0; + } + + public void advanceTo(State state, int componentIndex) { + this.componentIndex = componentIndex; + advanceTo(state); + } + + public InternalParser componentIndex(int componentIndex) { + this.componentIndex = componentIndex; + return this; + } + + public void index(int index) { + this.index = index; + } + + // Component capture + + public InternalParser resolveIfOpaque() { + boolean hasSlash = (this.uri.indexOf('/', this.index + 1) == -1); + this.isOpaque = (hasSlash && !hierarchicalSchemes.contains(this.scheme)); + return this; + } + + public InternalParser captureScheme() { + String scheme = captureComponent("scheme"); + this.scheme = (!scheme.contains("{") ? scheme.toLowerCase(Locale.ROOT) : scheme); + return this; + } + + public InternalParser captureUser() { + this.inPassword = false; + this.user = captureComponent("user"); + return this; + } + + public InternalParser captureHost() { + verify(this.remainingPercentEncodedChars == 0 && !this.inPassword, this, "Bad authority"); + this.host = captureComponent("host"); + return this; + } + + public InternalParser captureHostIfNotEmpty() { + if (this.index > this.componentIndex) { + captureHost(); + } + return this; + } + + public InternalParser capturePort() { + verify(this.openCurlyBracketCount == 0, this, "Bad authority"); + this.port = captureComponent("port"); + return this; + } + + public InternalParser capturePath() { + this.path = captureComponent("path"); + return this; + } + + public InternalParser captureQuery() { + this.query = captureComponent("query"); + return this; + } + + public void captureFragmentIfNotEmpty() { + if (this.index > this.componentIndex + 1) { + this.fragment = captureComponent("fragment"); + } + } + + public InternalParser switchPortForFullPassword() { + this.user = this.host + ":" + captureComponent(); + if (logger.isTraceEnabled()) { + logger.trace("Switching from host/port to user=" + this.user); + } + return this; + } + + public InternalParser switchPortForPassword() { + this.inPassword = true; + if (this.host != null) { + this.componentIndex = (this.componentIndex - this.host.length() - 1); + this.host = null; + if (logger.isTraceEnabled()) { + logger.trace("Switching from host/port to username/password"); + } + } + return this; + } + + private String captureComponent(String logPrefix) { + String value = captureComponent(); + if (logger.isTraceEnabled()) { + logger.trace(logPrefix + " set to '" + value + "'"); + } + return value; + } + + private String captureComponent() { + return this.uri.substring(this.componentIndex, this.index); + } + + public InternalParser markPercentEncoding() { + verify(this.remainingPercentEncodedChars == 0, this, "Bad encoding"); + this.remainingPercentEncodedChars = 2; + this.inUtf16Sequence = false; + return this; + } + + // Encoding and curly bracket handling + + /** + * Return true if character was part of percent encoded sequence. + */ + public boolean countDownPercentEncodingInHost(char c) { + if (this.remainingPercentEncodedChars == 0) { + return false; + } + this.remainingPercentEncodedChars--; + verifyIsHexDigit(c, this, "Bad authority"); + return true; + } + + /** + * Return true if character was part of percent encoded sequence. + */ + public boolean countDownPercentEncodingInPath(char c) { + if (this.remainingPercentEncodedChars == 0) { + return false; + } + if (this.remainingPercentEncodedChars == 2 && c == 'u' && !this.inUtf16Sequence) { + this.inUtf16Sequence = true; + this.remainingPercentEncodedChars = 4; + return true; + } + this.remainingPercentEncodedChars--; + verifyIsHexDigit(c, this, "Bad path"); + this.inUtf16Sequence &= (this.remainingPercentEncodedChars > 0); + return true; + } + + /** + * Return true if the character is within curly brackets. + */ + public boolean processCurlyBrackets(char c) { + if (c == '{') { + this.openCurlyBracketCount++; + return true; + } + else if (c == '}') { + if (this.openCurlyBracketCount > 0) { + this.openCurlyBracketCount--; + return true; + } + return false; + } + return (this.openCurlyBracketCount > 0); + } + + @Override + public String toString() { + return "[State=" + this.state + ", index=" + this.index + ", componentIndex=" + this.componentIndex + + ", uri='" + this.uri + "', scheme='" + this.scheme + "', user='" + this.user + + "', host='" + this.host + "', path='" + this.path + "', port='" + this.port + + "', query='" + this.query + "', fragment='" + this.fragment + "']"; + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/util/ServletContextPropertyUtils.java b/spring-web/src/main/java/org/springframework/web/util/ServletContextPropertyUtils.java index 6ce2c6e1a398..14a8e2f7b4cb 100644 --- a/spring-web/src/main/java/org/springframework/web/util/ServletContextPropertyUtils.java +++ b/spring-web/src/main/java/org/springframework/web/util/ServletContextPropertyUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2024 the original author 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 @@ * Helper class for resolving placeholders in texts. Usually applied to file paths. * *

        A text may contain {@code ${...}} placeholders, to be resolved as servlet context - * init parameters or system properties: e.g. {@code ${user.dir}}. Default values can + * init parameters or system properties: for example, {@code ${user.dir}}. Default values can * be supplied using the ":" separator between key and value. * * @author Juergen Hoeller @@ -39,11 +39,13 @@ public abstract class ServletContextPropertyUtils { private static final PropertyPlaceholderHelper strictHelper = new PropertyPlaceholderHelper(SystemPropertyUtils.PLACEHOLDER_PREFIX, - SystemPropertyUtils.PLACEHOLDER_SUFFIX, SystemPropertyUtils.VALUE_SEPARATOR, false); + SystemPropertyUtils.PLACEHOLDER_SUFFIX, SystemPropertyUtils.VALUE_SEPARATOR, + SystemPropertyUtils.ESCAPE_CHARACTER, false); private static final PropertyPlaceholderHelper nonStrictHelper = new PropertyPlaceholderHelper(SystemPropertyUtils.PLACEHOLDER_PREFIX, - SystemPropertyUtils.PLACEHOLDER_SUFFIX, SystemPropertyUtils.VALUE_SEPARATOR, true); + SystemPropertyUtils.PLACEHOLDER_SUFFIX, SystemPropertyUtils.VALUE_SEPARATOR, + SystemPropertyUtils.ESCAPE_CHARACTER, true); /** diff --git a/spring-web/src/main/java/org/springframework/web/util/ServletRequestPathUtils.java b/spring-web/src/main/java/org/springframework/web/util/ServletRequestPathUtils.java index 4c627896589c..279ceaae7497 100644 --- a/spring-web/src/main/java/org/springframework/web/util/ServletRequestPathUtils.java +++ b/spring-web/src/main/java/org/springframework/web/util/ServletRequestPathUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -179,6 +179,27 @@ public static boolean hasCachedPath(ServletRequest request) { request.getAttribute(UrlPathHelper.PATH_ATTRIBUTE) != null); } + /** + * Check if the Servlet is mapped by a path prefix, and if so return that + * path prefix. + * @param request the current request + * @return the prefix, or {@code null} if the Servlet is not mapped by prefix + * @since 6.2.3 + */ + @Nullable + public static String getServletPathPrefix(HttpServletRequest request) { + HttpServletMapping mapping = (HttpServletMapping) request.getAttribute(RequestDispatcher.INCLUDE_MAPPING); + mapping = (mapping != null ? mapping : request.getHttpServletMapping()); + if (ObjectUtils.nullSafeEquals(mapping.getMappingMatch(), MappingMatch.PATH)) { + String servletPath = (String) request.getAttribute(WebUtils.INCLUDE_SERVLET_PATH_ATTRIBUTE); + servletPath = (servletPath != null ? servletPath : request.getServletPath()); + servletPath = (servletPath.endsWith("/") ? servletPath.substring(0, servletPath.length() - 1) : servletPath); + return servletPath; + } + return null; + } + + /** * Simple wrapper around the default {@link RequestPath} implementation that @@ -251,22 +272,11 @@ public static RequestPath parse(HttpServletRequest request) { String requestUri = (String) request.getAttribute(WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE); requestUri = (requestUri != null ? requestUri : request.getRequestURI()); String servletPathPrefix = getServletPathPrefix(request); - return (StringUtils.hasText(servletPathPrefix) ? - new ServletRequestPath(new PathElements(requestUri, request.getContextPath(), servletPathPrefix)) : - RequestPath.parse(requestUri, request.getContextPath())); - } - - @Nullable - private static String getServletPathPrefix(HttpServletRequest request) { - HttpServletMapping mapping = (HttpServletMapping) request.getAttribute(RequestDispatcher.INCLUDE_MAPPING); - mapping = (mapping != null ? mapping : request.getHttpServletMapping()); - if (ObjectUtils.nullSafeEquals(mapping.getMappingMatch(), MappingMatch.PATH)) { - String servletPath = (String) request.getAttribute(WebUtils.INCLUDE_SERVLET_PATH_ATTRIBUTE); - servletPath = (servletPath != null ? servletPath : request.getServletPath()); - servletPath = (servletPath.endsWith("/") ? servletPath.substring(0, servletPath.length() - 1) : servletPath); - return UriUtils.encodePath(servletPath, StandardCharsets.UTF_8); + if (!StringUtils.hasLength(servletPathPrefix)) { + return RequestPath.parse(requestUri, request.getContextPath()); } - return null; + servletPathPrefix = UriUtils.encodePath(servletPathPrefix, StandardCharsets.UTF_8); + return new ServletRequestPath(new PathElements(requestUri, request.getContextPath(), servletPathPrefix)); } record PathElements(String rawPath, @Nullable String contextPath, String servletPathPrefix) { diff --git a/spring-web/src/main/java/org/springframework/web/util/TagUtils.java b/spring-web/src/main/java/org/springframework/web/util/TagUtils.java index c62ff3adac5b..036021a81d07 100644 --- a/spring-web/src/main/java/org/springframework/web/util/TagUtils.java +++ b/spring-web/src/main/java/org/springframework/web/util/TagUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-web/src/main/java/org/springframework/web/util/UriBuilder.java b/spring-web/src/main/java/org/springframework/web/util/UriBuilder.java index 6b0b2987e5f6..b468038af99e 100644 --- a/spring-web/src/main/java/org/springframework/web/util/UriBuilder.java +++ b/spring-web/src/main/java/org/springframework/web/util/UriBuilder.java @@ -166,7 +166,7 @@ public interface UriBuilder { * Append the given query parameter. Both the parameter name and values may * contain URI template variables to be expanded later from values. If no * values are given, the resulting URI will contain the query parameter name - * only, e.g. {@code "?foo"} instead of {@code "?foo=bar"}. + * only, for example, {@code "?foo"} instead of {@code "?foo=bar"}. *

        Note: encoding, if applied, will only encode characters * that are illegal in a query parameter name or value such as {@code "="} * or {@code "&"}. All others that are legal as per syntax rules in 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 d709130ca245..f436bc2e96e3 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 @@ -16,7 +16,6 @@ package org.springframework.web.util; -import java.net.InetSocketAddress; import java.net.URI; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; @@ -26,7 +25,6 @@ import java.util.Deque; import java.util.HashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.regex.Matcher; @@ -44,19 +42,23 @@ import org.springframework.web.util.UriComponents.UriTemplateVariables; /** - * Builder for {@link UriComponents}. - * - *

        Typical usage involves: + * Builder for {@link UriComponents}. Use as follows: *

          - *
        1. Create a {@code UriComponentsBuilder} with one of the static factory methods - * (such as {@link #fromPath(String)} or {@link #fromUri(URI)})
        2. - *
        3. Set the various URI components through the respective methods ({@link #scheme(String)}, - * {@link #userInfo(String)}, {@link #host(String)}, {@link #port(int)}, {@link #path(String)}, - * {@link #pathSegment(String...)}, {@link #queryParam(String, Object...)}, and - * {@link #fragment(String)}.
        4. - *
        5. Build the {@link UriComponents} instance with the {@link #build()} method.
        6. + *
        7. Create a builder through a factory method, e.g. {@link #fromUriString(String)}. + *
        8. Set URI components (e.g. scheme, host, path, etc) through instance methods. + *
        9. Build the {@link UriComponents}.
        10. + *
        11. Expand URI variables from a map or array or variable values. + *
        12. Encode via {@link UriComponents#encode()}.
        13. + *
        14. Use {@link UriComponents#toUri()} or {@link UriComponents#toUriString()}. *
        * + *

        By default, URI parsing is based on the {@link ParserType#RFC RFC parser type}, + * which expects input strings to conform to RFC 3986 syntax. The alternative + * {@link ParserType#WHAT_WG WhatWG parser type}, based on the algorithm from + * the WhatWG URL Living Standard + * provides more lenient handling of a wide range of cases that occur in user + * types URL's. + * * @author Arjen Poutsma * @author Rossen Stoyanchev * @author Phillip Webb @@ -73,38 +75,8 @@ public class UriComponentsBuilder implements UriBuilder, Cloneable { private static final Pattern QUERY_PARAM_PATTERN = Pattern.compile("([^&=]+)(=?)([^&]+)?"); - private static final String SCHEME_PATTERN = "([^:/?#\\\\]+):"; - - private static final String HTTP_PATTERN = "(?i)(http|https):"; - - private static final String USERINFO_PATTERN = "([^/?#\\\\]*)"; - - private static final String HOST_IPV4_PATTERN = "[^/?#:\\\\]*"; - - private static final String HOST_IPV6_PATTERN = "\\[[\\p{XDigit}:.]*[%\\p{Alnum}]*]"; - - private static final String HOST_PATTERN = "(" + HOST_IPV6_PATTERN + "|" + HOST_IPV4_PATTERN + ")"; - - private static final String PORT_PATTERN = "(\\{[^}]+\\}?|[^/?#\\\\]*)"; - - private static final String PATH_PATTERN = "([^?#]*)"; - - private static final String QUERY_PATTERN = "([^#]*)"; - - private static final String LAST_PATTERN = "(.*)"; - - // Regex patterns that matches URIs. See RFC 3986, appendix B - private static final Pattern URI_PATTERN = Pattern.compile( - "^(" + SCHEME_PATTERN + ")?" + "(//(" + USERINFO_PATTERN + "@)?" + HOST_PATTERN + "(:" + PORT_PATTERN + - ")?" + ")?" + PATH_PATTERN + "(\\?" + QUERY_PATTERN + ")?" + "(#" + LAST_PATTERN + ")?"); - - private static final Pattern HTTP_URL_PATTERN = Pattern.compile( - "^" + HTTP_PATTERN + "(//(" + USERINFO_PATTERN + "@)?" + HOST_PATTERN + "(:" + PORT_PATTERN + ")?" + ")?" + - PATH_PATTERN + "(\\?" + QUERY_PATTERN + ")?" + "(#" + LAST_PATTERN + ")?"); - private static final Object[] EMPTY_VALUES = new Object[0]; - @Nullable private String scheme; @@ -203,7 +175,19 @@ public static UriComponentsBuilder fromUri(URI uri) { } /** - * Create a builder that is initialized with the given URI string. + * Variant of {@link #fromUriString(String, ParserType)} that defaults to + * the {@link ParserType#RFC} parsing. + */ + public static UriComponentsBuilder fromUriString(String uri) throws InvalidUrlException { + Assert.notNull(uri, "URI must not be null"); + if (uri.isEmpty()) { + return new UriComponentsBuilder(); + } + return fromUriString(uri, ParserType.RFC); + } + + /** + * Create a builder that is initialized by parsing the given URI string. *

        Note: The presence of reserved characters can prevent * correct parsing of the URI string. For example if a query parameter * contains {@code '='} or {@code '&'} characters, the query string cannot @@ -214,53 +198,28 @@ public static UriComponentsBuilder fromUri(URI uri) { * UriComponentsBuilder.fromUriString(uriString).buildAndExpand("hot&cold"); * * @param uri the URI string to initialize with + * @param parserType the parsing algorithm to use * @return the new {@code UriComponentsBuilder} + * @throws InvalidUrlException if {@code uri} cannot be parsed + * @since 6.2 */ - public static UriComponentsBuilder fromUriString(String uri) { + public static UriComponentsBuilder fromUriString(String uri, ParserType parserType) throws InvalidUrlException { Assert.notNull(uri, "URI must not be null"); - Matcher matcher = URI_PATTERN.matcher(uri); - if (matcher.matches()) { - UriComponentsBuilder builder = new UriComponentsBuilder(); - String scheme = matcher.group(2); - String userInfo = matcher.group(5); - String host = matcher.group(6); - String port = matcher.group(8); - String path = matcher.group(9); - String query = matcher.group(11); - String fragment = matcher.group(13); - boolean opaque = false; - if (StringUtils.hasLength(scheme)) { - String rest = uri.substring(scheme.length()); - if (!rest.startsWith(":/")) { - opaque = true; - } - } - builder.scheme(scheme); - if (opaque) { - String ssp = uri.substring(scheme.length() + 1); - if (StringUtils.hasLength(fragment)) { - ssp = ssp.substring(0, ssp.length() - (fragment.length() + 1)); - } - builder.schemeSpecificPart(ssp); - } - else { - checkSchemeAndHost(uri, scheme, host); - builder.userInfo(userInfo); - builder.host(host); - if (StringUtils.hasLength(port)) { - builder.port(port); - } - builder.path(path); - builder.query(query); + if (uri.isEmpty()) { + return new UriComponentsBuilder(); + } + UriComponentsBuilder builder = new UriComponentsBuilder(); + return switch (parserType) { + case RFC -> { + RfcUriParser.UriRecord record = RfcUriParser.parse(uri); + yield builder.rfcUriRecord(record); } - if (StringUtils.hasText(fragment)) { - builder.fragment(fragment); + case WHAT_WG -> { + WhatWgUrlParser.UrlRecord record = + WhatWgUrlParser.parse(uri, WhatWgUrlParser.EMPTY_RECORD, null, null); + yield builder.whatWgUrlRecord(record); } - return builder; - } - else { - throw new IllegalArgumentException("[" + uri + "] is not a valid URI"); - } + }; } /** @@ -276,42 +235,12 @@ public static UriComponentsBuilder fromUriString(String uri) { * * @param httpUrl the source URI * @return the URI components of the URI + * @deprecated as of 6.2, in favor of {@link #fromUriString(String)}; + * scheduled for removal in 7.0. */ - public static UriComponentsBuilder fromHttpUrl(String httpUrl) { - Assert.notNull(httpUrl, "HTTP URL must not be null"); - Matcher matcher = HTTP_URL_PATTERN.matcher(httpUrl); - if (matcher.matches()) { - UriComponentsBuilder builder = new UriComponentsBuilder(); - String scheme = matcher.group(1); - builder.scheme(scheme != null ? scheme.toLowerCase(Locale.ROOT) : null); - builder.userInfo(matcher.group(4)); - String host = matcher.group(5); - checkSchemeAndHost(httpUrl, scheme, host); - builder.host(host); - String port = matcher.group(7); - if (StringUtils.hasLength(port)) { - builder.port(port); - } - builder.path(matcher.group(8)); - builder.query(matcher.group(10)); - String fragment = matcher.group(12); - if (StringUtils.hasText(fragment)) { - builder.fragment(fragment); - } - return builder; - } - else { - throw new IllegalArgumentException("[" + httpUrl + "] is not a valid HTTP URL"); - } - } - - private static void checkSchemeAndHost(String uri, @Nullable String scheme, @Nullable String host) { - if (StringUtils.hasLength(scheme) && scheme.startsWith("http") && !StringUtils.hasLength(host)) { - throw new IllegalArgumentException("[" + uri + "] is not a valid HTTP URL"); - } - if (StringUtils.hasLength(host) && host.startsWith("[") && !host.endsWith("]")) { - throw new IllegalArgumentException("Invalid IPV6 host in [" + uri + "]"); - } + @Deprecated(since = "6.2") + public static UriComponentsBuilder fromHttpUrl(String httpUrl) throws InvalidUrlException { + return fromUriString(httpUrl); } /** @@ -324,57 +253,22 @@ private static void checkSchemeAndHost(String uri, @Nullable String scheme, @Nul * @return the URI components of the URI * @since 4.1.5 * @deprecated in favor of {@link ForwardedHeaderUtils#adaptFromForwardedHeaders}; - * to be removed in 6.2 + * to be removed in 7.0 */ @Deprecated(since = "6.1", forRemoval = true) public static UriComponentsBuilder fromHttpRequest(HttpRequest request) { return ForwardedHeaderUtils.adaptFromForwardedHeaders(request.getURI(), request.getHeaders()); } - /** - * Parse the first "Forwarded: for=..." or "X-Forwarded-For" header value to - * an {@code InetSocketAddress} representing the address of the client. - * @param request a request with headers that may contain forwarded headers - * @param remoteAddress the current remoteAddress - * @return an {@code InetSocketAddress} with the extracted host and port, or - * {@code null} if the headers are not present. - * @since 5.3 - * @deprecated in favor of {@link ForwardedHeaderUtils#parseForwardedFor}; - * to be removed in 6.2 - */ - @Deprecated(since = "6.1", forRemoval = true) - @Nullable - public static InetSocketAddress parseForwardedFor( - HttpRequest request, @Nullable InetSocketAddress remoteAddress) { - - return ForwardedHeaderUtils.parseForwardedFor( - request.getURI(), request.getHeaders(), remoteAddress); - } - /** * Create an instance by parsing the "Origin" header of an HTTP request. * @see RFC 6454 + * @deprecated in favor of {@link UriComponentsBuilder#fromUriString(String)}; + * to be removed in 7.0 */ + @Deprecated(since = "6.2", forRemoval = true) public static UriComponentsBuilder fromOriginHeader(String origin) { - Matcher matcher = URI_PATTERN.matcher(origin); - if (matcher.matches()) { - UriComponentsBuilder builder = new UriComponentsBuilder(); - String scheme = matcher.group(2); - String host = matcher.group(6); - String port = matcher.group(8); - if (StringUtils.hasLength(scheme)) { - builder.scheme(scheme); - } - builder.host(host); - if (StringUtils.hasLength(port)) { - builder.port(port); - } - checkSchemeAndHost(origin, scheme, host); - return builder; - } - else { - throw new IllegalArgumentException("[" + origin + "] is not a valid \"Origin\" header value"); - } + return fromUriString(origin); } @@ -507,6 +401,7 @@ public URI build(Map uriVariables) { * @since 4.1 * @see UriComponents#toUriString() */ + @Override public String toUriString() { return (this.uriVariables.isEmpty() ? build().encode().toUriString() : @@ -569,6 +464,58 @@ public UriComponentsBuilder uriComponents(UriComponents uriComponents) { return this; } + /** + * Internal method to initialize this builder from an RFC {@code UriRecord}. + */ + private UriComponentsBuilder rfcUriRecord(RfcUriParser.UriRecord record) { + scheme(record.scheme()); + if (record.isOpaque()) { + if (record.path() != null) { + schemeSpecificPart(record.path()); + } + } + else { + userInfo(record.user()); + host(record.host()); + port(record.port()); + if (record.path() != null) { + path(record.path()); + } + query(record.query()); + } + fragment(record.fragment()); + return this; + } + + /** + * Internal method to initialize this builder from a WhatWG {@code UrlRecord}. + */ + private UriComponentsBuilder whatWgUrlRecord(WhatWgUrlParser.UrlRecord record) { + if (!record.scheme().isEmpty()) { + scheme(record.scheme()); + } + if (record.path().isOpaque()) { + String ssp = record.path() + record.search(); + schemeSpecificPart(ssp); + } + else { + userInfo(record.userInfo()); + String hostname = record.hostname(); + if (StringUtils.hasText(hostname)) { + host(hostname); + } + if (record.port() != null) { + port(record.portString()); + } + path(record.path().toString()); + query(record.query()); + } + if (StringUtils.hasText(record.fragment())) { + fragment(record.fragment()); + } + return this; + } + @Override public UriComponentsBuilder scheme(@Nullable String scheme) { this.scheme = scheme; @@ -833,6 +780,30 @@ public UriComponentsBuilder cloneBuilder() { } + /** + * Enum to provide a choice of URI parsers to use in {@link #fromUriString(String, ParserType)}. + * @since 6.2 + */ + public enum ParserType { + + /** + * This parser type expects URI's to conform to RFC 3986 syntax. + */ + RFC, + + /** + * This parser follows the + * URL parsing algorithm + * in the WhatWG URL Living standard that browsers implement to align on + * lenient handling of user typed URL's that may not conform to RFC syntax. + * @see URL Living Standard + * @see URL tests + */ + WHAT_WG + + } + + private interface PathComponentBuilder { @Nullable @@ -929,7 +900,7 @@ public void append(String path) { @Override @Nullable public PathComponent build() { - if (this.path.length() == 0) { + if (this.path.isEmpty()) { return null; } String sanitized = getSanitizedPath(this.path); diff --git a/spring-web/src/main/java/org/springframework/web/util/UriTemplate.java b/spring-web/src/main/java/org/springframework/web/util/UriTemplate.java index 94dfb9de617b..fd011edb6392 100644 --- a/spring-web/src/main/java/org/springframework/web/util/UriTemplate.java +++ b/spring-web/src/main/java/org/springframework/web/util/UriTemplate.java @@ -31,14 +31,14 @@ /** * Representation of a URI template that can be expanded with URI variables via - * {@link #expand(Map)}, {@link #expand(Object[])}, or matched to a URL via + * {@link #expand(Map)} or {@link #expand(Object[])}, or matched to a URL via * {@link #match(String)}. This class is designed to be thread-safe and * reusable, and allows any number of expand or match calls. * *

        Note: this class uses {@link UriComponentsBuilder} * internally to expand URI templates, and is merely a shortcut for already * prepared URI templates. For more dynamic preparation and extra flexibility, - * e.g. around URI encoding, consider using {@code UriComponentsBuilder} or the + * for example, around URI encoding, consider using {@code UriComponentsBuilder} or the * higher level {@link DefaultUriBuilderFactory} which adds several encoding * modes on top of {@code UriComponentsBuilder}. See the * reference docs @@ -77,7 +77,7 @@ public UriTemplate(String uriTemplate) { /** - * Return the names of the variables in the template, in order. + * Return the names of the variables in this template, in order. * @return the template variable names */ public List getVariableNames() { @@ -85,16 +85,16 @@ public List getVariableNames() { } /** - * Given the Map of variables, expands this template into a URI. The Map keys represent variable names, - * the Map values variable values. The order of variables is not significant. + * Given the Map of variables, expand this template into a URI. + *

        The Map keys represent variable names, and the Map values represent + * variable values. The order of variables is not significant. *

        Example: *

         	 * UriTemplate template = new UriTemplate("https://example.com/hotels/{hotel}/bookings/{booking}");
        -	 * Map<String, String> uriVariables = new HashMap<String, String>();
        -	 * uriVariables.put("booking", "42");
        -	 * uriVariables.put("hotel", "Rest & Relax");
        -	 * System.out.println(template.expand(uriVariables));
        -	 * 
        + * Map<String, String> uriVariables = Map.of( + * "booking", "42", + * "hotel", "Rest & Relax"); + * System.out.println(template.expand(uriVariables)); * will print:
        {@code https://example.com/hotels/Rest%20%26%20Relax/bookings/42}
        * @param uriVariables the map of URI variables * @return the expanded URI @@ -108,13 +108,13 @@ public URI expand(Map uriVariables) { } /** - * Given an array of variables, expand this template into a full URI. The array represent variable values. - * The order of variables is significant. + * Given the array of variables, expand this template into a full URI. + *

        The array represents variable values, and the order of variables is + * significant. *

        Example: *

         	 * UriTemplate template = new UriTemplate("https://example.com/hotels/{hotel}/bookings/{booking}");
        -	 * System.out.println(template.expand("Rest & Relax", 42));
        -	 * 
        + * System.out.println(template.expand("Rest & Relax", 42)); * will print:
        {@code https://example.com/hotels/Rest%20%26%20Relax/bookings/42}
        * @param uriVariableValues the array of URI variables * @return the expanded URI @@ -141,13 +141,13 @@ public boolean matches(@Nullable String uri) { } /** - * Match the given URI to a map of variable values. Keys in the returned map are variable names, - * values are variable values, as occurred in the given URI. + * Match the given URI to a map of variable values based on this template. + *

        Keys in the returned map are variable names, and the values in the + * returned map are variable values, as present in the given URI. *

        Example: *

         	 * UriTemplate template = new UriTemplate("https://example.com/hotels/{hotel}/bookings/{booking}");
        -	 * System.out.println(template.match("https://example.com/hotels/1/bookings/42"));
        -	 * 
        + * System.out.println(template.match("https://example.com/hotels/1/bookings/42")); * will print:
        {@code {hotel=1, booking=42}}
        * @param uri the URI to match to * @return a map of variable values diff --git a/spring-web/src/main/java/org/springframework/web/util/UriUtils.java b/spring-web/src/main/java/org/springframework/web/util/UriUtils.java index c7b8aa409fac..c8faec81c9b3 100644 --- a/spring-web/src/main/java/org/springframework/web/util/UriUtils.java +++ b/spring-web/src/main/java/org/springframework/web/util/UriUtils.java @@ -34,7 +34,7 @@ * *

        There are two types of encode methods: *

          - *
        • {@code "encodeXyz"} -- these encode a specific URI component (e.g. path, + *
        • {@code "encodeXyz"} -- these encode a specific URI component (for example, path, * query) by percent encoding illegal characters, which includes non-US-ASCII * characters, and also characters that are otherwise illegal within the given * URI component type, as defined in RFC 3986. The effect of this method, with @@ -389,8 +389,8 @@ public static String decode(String source, Charset charset) { /** * Extract the file extension from the given URI path. - * @param path the URI path (e.g. "/products/index.html") - * @return the extracted file extension (e.g. "html") + * @param path the URI path (for example, "/products/index.html") + * @return the extracted file extension (for example, "html") * @since 4.3.2 */ @Nullable diff --git a/spring-web/src/main/java/org/springframework/web/util/UrlPathHelper.java b/spring-web/src/main/java/org/springframework/web/util/UrlPathHelper.java index 1efa02fdc6e4..c6bd2a2956dd 100644 --- a/spring-web/src/main/java/org/springframework/web/util/UrlPathHelper.java +++ b/spring-web/src/main/java/org/springframework/web/util/UrlPathHelper.java @@ -285,11 +285,11 @@ public String getPathWithinServletMapping(HttpServletRequest request) { * i.e. the part of the request's URL beyond the part that called the servlet, * or "" if the whole URL has been used to identify the servlet. *

          Detects include request URL if called within a RequestDispatcher include. - *

          E.g.: servlet mapping = "/*"; request URI = "/test/a" → "/test/a". - *

          E.g.: servlet mapping = "/"; request URI = "/test/a" → "/test/a". - *

          E.g.: servlet mapping = "/test/*"; request URI = "/test/a" → "/a". - *

          E.g.: servlet mapping = "/test"; request URI = "/test" → "". - *

          E.g.: servlet mapping = "/*.test"; request URI = "/a.test" → "". + *

          For example: servlet mapping = "/*"; request URI = "/test/a" → "/test/a". + *

          For example: servlet mapping = "/"; request URI = "/test/a" → "/test/a". + *

          For example: servlet mapping = "/test/*"; request URI = "/test/a" → "/a". + *

          For example: servlet mapping = "/test"; request URI = "/test" → "". + *

          For example: servlet mapping = "/*.test"; request URI = "/a.test" → "". * @param request current HTTP request * @param pathWithinApp a precomputed path within the application * @return the path within the servlet mapping, or "" @@ -318,7 +318,7 @@ protected String getPathWithinServletMapping(HttpServletRequest request, String String pathInfo = request.getPathInfo(); if (pathInfo != null) { // Use path info if available. Indicates index page within a servlet mapping? - // e.g. with index page: URI="/", servletPath="/index.html" + // for example, with index page: URI="/", servletPath="/index.html" return pathInfo; } if (!this.urlDecode) { diff --git a/spring-web/src/main/java/org/springframework/web/util/WebAppRootListener.java b/spring-web/src/main/java/org/springframework/web/util/WebAppRootListener.java index d96231b501c2..8817513e6f86 100644 --- a/spring-web/src/main/java/org/springframework/web/util/WebAppRootListener.java +++ b/spring-web/src/main/java/org/springframework/web/util/WebAppRootListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2024 the original author 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,9 @@ * at least when used for log4j. Log4jConfigListener sets the system property * implicitly, so there's no need for this listener in addition to it. * - *

          WARNING: Some containers, e.g. Tomcat, do NOT keep system properties separate + *

          WARNING: Some containers, for example, Tomcat, do NOT keep system properties separate * per web app. You have to use unique "webAppRootKey" context-params per web app - * then, to avoid clashes. Other containers like Resin do isolate each web app's + * then, to avoid clashes. Other containers do isolate each web app's * system properties: Here you can use the default key (i.e. no "webAppRootKey" * context-param at all) without worrying. * diff --git a/spring-web/src/main/java/org/springframework/web/util/WebUtils.java b/spring-web/src/main/java/org/springframework/web/util/WebUtils.java index 25d56cf8d8cc..b5d8fa8fc2cb 100644 --- a/spring-web/src/main/java/org/springframework/web/util/WebUtils.java +++ b/spring-web/src/main/java/org/springframework/web/util/WebUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -630,7 +630,7 @@ public static String findParameterValue(ServletRequest request, String name) { *

            *
          1. Try to get the parameter value using just the given logical name. * This handles parameters of the form {@code logicalName = value}. For normal - * parameters, e.g. submitted using a hidden HTML form field, this will return + * parameters, for example, submitted using a hidden HTML form field, this will return * the requested value.
          2. *
          3. Try to obtain the parameter value from the parameter name, where the * parameter name in the request is of the form {@code logicalName_value = xyz} @@ -815,7 +815,7 @@ public static boolean isSameOrigin(HttpRequest request) { port = uri.getPort(); } - UriComponents originUrl = UriComponentsBuilder.fromOriginHeader(origin).build(); + UriComponents originUrl = UriComponentsBuilder.fromUriString(origin).build(); return (ObjectUtils.nullSafeEquals(scheme, originUrl.getScheme()) && ObjectUtils.nullSafeEquals(host, originUrl.getHost()) && getPort(scheme, port) == getPort(originUrl.getScheme(), originUrl.getPort())); diff --git a/spring-web/src/main/java/org/springframework/web/util/WhatWgUrlParser.java b/spring-web/src/main/java/org/springframework/web/util/WhatWgUrlParser.java new file mode 100644 index 000000000000..44cb470d30e7 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/WhatWgUrlParser.java @@ -0,0 +1,3140 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.util; + +import java.net.IDN; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.IntPredicate; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Implementation of the + * URL parsing algorithm + * of the WhatWG URL Living standard. Browsers use this algorithm to align on + * lenient parsing of user typed URL's that may deviate from RFC syntax. + * Use this, via {@link UriComponentsBuilder.ParserType#WHAT_WG}, if you need to + * leniently handle URL's that don't confirm to RFC syntax, or for alignment + * with browser behavior. + * + *

            Comments in this class correlate to the parsing algorithm. + * The implementation differs from the spec in the following ways: + *

              + *
            • Supports URI template variables within URI components. + *
            • Consequently, the port is a String and not an integer. + *
            • Prepends '/' to each segment to ensure trailing slashes are significant. + *
            + * All of these modifications have been indicated through comments that start + * with {@code EXTRA}. + * + * @author Arjen Poutsma + * @since 6.2 + */ +@SuppressWarnings({"SameParameterValue", "BooleanMethodIsAlwaysInverted"}) +final class WhatWgUrlParser { + + public static final UrlRecord EMPTY_RECORD = new UrlRecord(); + + private static final Log logger = LogFactory.getLog(WhatWgUrlParser.class); + + private static final int EOF = -1; + + private static final int MAX_PORT = 65535; + + + private final StringBuilder input; + + @Nullable + private final UrlRecord base; + + @Nullable + private Charset encoding; + + @Nullable + private final Consumer validationErrorHandler; + + private int pointer; + + private final StringBuilder buffer; + + @Nullable + private State state; + + @Nullable + private State stateOverride; + + private boolean atSignSeen; + + private boolean passwordTokenSeen; + + private boolean insideBrackets; + + private int openCurlyBracketCount; + + private boolean stopMainLoop = false; + + + private WhatWgUrlParser( + String input, @Nullable UrlRecord base, @Nullable Charset encoding, + @Nullable Consumer validationErrorHandler) { + + this.input = new StringBuilder(input); + this.base = base; + this.encoding = encoding; + this.validationErrorHandler = validationErrorHandler; + this.buffer = new StringBuilder(this.input.length() / 2); + } + + + /** + * Parse the given input into a URL record. + * @param input the scalar value string + * @param base the optional base URL to resolve relative URLs against. If + * {@code null}, relative URLs cannot be parsed. + * @param encoding the optional encoding to use. If {@code null}, no + * encoding is performed. + * @param validationErrorHandler optional consumer for non-fatal URL + * validation messages + * @return a URL record, as defined in the + * living URL + * specification + * @throws InvalidUrlException if the {@code input} does not contain a + * parsable URL + */ + public static UrlRecord parse(String input, @Nullable UrlRecord base, + @Nullable Charset encoding, @Nullable Consumer validationErrorHandler) + throws InvalidUrlException { + + Assert.notNull(input, "Input must not be null"); + + WhatWgUrlParser parser = new WhatWgUrlParser(input, base, encoding, validationErrorHandler); + return parser.basicUrlParser(null, null); + } + + /** + * The basic URL parser takes a scalar value string input, with an optional + * null or base URL base (default null), an optional encoding (default UTF-8), + * and optionally, a UrlRecord and/or State overrides to start from. + */ + private UrlRecord basicUrlParser(@Nullable UrlRecord url, @Nullable State stateOverride) { + // If url is not given: + if (url == null) { + // Set url to a new URL. + url = new UrlRecord(); + sanitizeInput(true); + } + else { + sanitizeInput(false); + } + + // Let state be state override if given, or scheme start state otherwise. + this.state = (stateOverride != null ? stateOverride : State.SCHEME_START); + this.stateOverride = stateOverride; + + // Keep running the following state machine by switching on state. + // If after a run pointer points to the EOF code point, go to the next step. + // Otherwise, increase pointer by 1 and continue with the state machine. + while (!this.stopMainLoop && this.pointer <= this.input.length()) { + int c; + if (this.pointer < this.input.length()) { + c = this.input.codePointAt(this.pointer); + } + else { + c = EOF; + } + if (logger.isTraceEnabled()) { + logger.trace("current: " + (c != EOF ? Character.toString(c) : "EOF") + + " ptr: " + this.pointer + " Buffer: " + this.buffer + " State: " + this.state); + } + this.state.handle(c, url, this); + this.pointer++; + } + return url; + } + + void sanitizeInput(boolean removeC0ControlOrSpace) { + boolean strip = true; + for (int i = 0; i < this.input.length(); i++) { + int c = this.input.codePointAt(i); + boolean isSpaceOrC0 = (c == ' ' || isC0Control(c)); + boolean isTabOrNL = (c == '\t' || isNewline(c)); + if ((strip && isSpaceOrC0) || isTabOrNL) { + if (validate()) { + // If input contains leading (or trailing) C0 control or space, invalid-URL-unit validation error. + // If input contains ASCII tab or newline, invalid-URL-unit validation error. + validationError("Code point \"" + c + "\" is not a URL unit."); + } + // Remove any leading C0 control or space from input. + if (removeC0ControlOrSpace && isSpaceOrC0) { + this.input.deleteCharAt(i); + } + else if (isTabOrNL) { + // Remove all ASCII tab or newline from input. + this.input.deleteCharAt(i); + } + i--; + } + else { + strip = false; + } + } + if (removeC0ControlOrSpace) { + for (int i = this.input.length() - 1; i >= 0; i--) { + int c = this.input.codePointAt(i); + if (c == ' ' || isC0Control(c)) { + if (validate()) { + // If input contains (leading or) trailing C0 control or space, invalid-URL-unit validation error. + validationError("Code point \"" + c + "\" is not a URL unit."); + } + // Remove any trailing C0 control or space from input. + this.input.deleteCharAt(i); + } + else { + break; + } + } + } + } + + private void setState(State newState) { + if (logger.isTraceEnabled()) { + String c; + if (this.pointer < this.input.length()) { + c = Character.toString(this.input.codePointAt(this.pointer)); + } + else { + c = "EOF"; + } + logger.trace("Changing state from " + this.state + " to " + newState + " (cur: " + c + ")"); + } + this.state = newState; + this.openCurlyBracketCount = (this.buffer.toString().equals("{") ? this.openCurlyBracketCount : 0); + } + + private boolean processCurlyBrackets(int c) { + if (c == '{') { + this.openCurlyBracketCount++; + return true; + } + if (c == '}') { + if (this.openCurlyBracketCount > 0) { + this.openCurlyBracketCount--; + return true; + } + return false; + } + return (this.openCurlyBracketCount > 0 && c != EOF); + } + + private static LinkedList strictSplit(String input, int delimiter) { + // Let position be a position variable for input, initially pointing at the start of input. + int position = 0; + // Let tokens be a list of strings, initially empty. + LinkedList tokens = new LinkedList<>(); + // Let token be the result of collecting a sequence of code points that are not equal to delimiter from input, given position. + int delIdx = input.indexOf(delimiter, position); + String token = (delIdx != EOF) ? input.substring(position, delIdx) : input.substring(position); + position = delIdx; + // Append token to tokens. + tokens.add(token); + // While position is not past the end of input: + while (position != EOF) { + // Assert: the code point at position within input is delimiter. + Assert.state(input.codePointAt(position) == delimiter, "Codepoint is not a delimiter"); + // Advance position by 1. + position++; + delIdx = input.indexOf(delimiter, position); + // Let token be the result of collecting a sequence of code points + // that are not equal to delimiter from input, given position. + token = (delIdx != EOF) ? input.substring(position, delIdx) : input.substring(position); + position = delIdx; + // Append token to tokens. + tokens.add(token); + } + return tokens; + } + + private static String domainToAscii(String domain, boolean beStrict) { + // If beStrict is false, domain is an ASCII string, and strictly splitting domain on U+002E (.) + // does not produce any item that starts with an ASCII case-insensitive match for "xn--", + // this step is equivalent to ASCII lowercasing domain. + if (!beStrict && containsOnlyAscii(domain)) { + int dotIdx = domain.indexOf('.'); + boolean onlyLowerCase = true; + while (dotIdx != -1) { + if (domain.length() - dotIdx > 4) { + // ASCII case-insensitive match for "xn--" + int ch0 = domain.codePointAt(dotIdx + 1); + int ch1 = domain.codePointAt(dotIdx + 2); + int ch2 = domain.codePointAt(dotIdx + 3); + int ch3 = domain.codePointAt(dotIdx + 4); + if ((ch0 == 'x' || ch0 == 'X') && + (ch1 == 'n' || ch1 == 'N') && + ch2 == '-' && ch3 == '_') { + onlyLowerCase = false; + break; + } + } + dotIdx = domain.indexOf('.', dotIdx + 1); + } + if (onlyLowerCase) { + return domain.toLowerCase(Locale.ENGLISH); + } + } + // Let result be the result of running Unicode ToASCII (https://www.unicode.org/reports/tr46/#ToASCII) + // with domain_name set to domain, UseSTD3ASCIIRules set to beStrict, CheckHyphens set to false, + // CheckBidi set to true, CheckJoiners set to true, Transitional_Processing set to false, + // and VerifyDnsLength set to beStrict. [UTS46] + int flag = 0; + if (beStrict) { + flag |= IDN.USE_STD3_ASCII_RULES; + } + // Implementation note: implementing Unicode ToASCII is beyond the scope of this parser, + // we use java.net.IDN.toASCII + try { + return IDN.toASCII(domain, flag); + } + catch (IllegalArgumentException ex) { + throw new InvalidUrlException( + "Could not convert \"" + domain + "\" to ASCII: " + ex.getMessage(), ex); + } + } + + private boolean validate() { + return this.validationErrorHandler != null; + } + + private void validationError(@Nullable String additionalInfo) { + if (this.validationErrorHandler != null) { + StringBuilder message = new StringBuilder("URL validation error for URL ["); + message.append(this.input); + message.append("]@"); + message.append(this.pointer); + if (additionalInfo != null) { + message.append(". "); + message.append(additionalInfo); + } + this.validationErrorHandler.accept(message.toString()); + } + } + + + private void failure(@Nullable String additionalInfo) { + StringBuilder message = new StringBuilder("URL parsing failure for URL ["); + message.append(this.input); + message.append("] @ "); + message.append(this.pointer); + if (additionalInfo != null) { + message.append(". "); + message.append(additionalInfo); + } + throw new InvalidUrlException(message.toString()); + } + + /** + * The C0 control percent-encode set are the C0 controls and all code points greater than U+007E (~). + */ + private static boolean c0ControlPercentEncodeSet(int ch) { + return (isC0Control(ch) || Integer.compareUnsigned(ch, '~') > 0); + } + + /** + * The fragment percent-encode set is the C0 control percent-encode set and + * U+0020 SPACE, U+0022 ("), U+003C (<), U+003E (>), and U+0060 (`). + */ + private static boolean fragmentPercentEncodeSet(int ch) { + return (c0ControlPercentEncodeSet(ch) || ch == ' ' || ch == '"' || ch == '<' || ch == '>' || ch == '`'); + } + + /** + * The query percent-encode set is the C0 control percent-encode set and + * U+0020 SPACE, U+0022 ("), U+0023 (#), U+003C (<), and U+003E (>). + */ + private static boolean queryPercentEncodeSet(int ch) { + return (c0ControlPercentEncodeSet(ch) || ch == ' ' || ch == '"' || ch == '#' || ch == '<' || ch == '>'); + } + + /** + * The special-query percent-encode set is the query percent-encode set and U+0027 ('). + */ + private static boolean specialQueryPercentEncodeSet(int ch) { + return (queryPercentEncodeSet(ch) || ch == '\''); + } + + + /** + * The path percent-encode set is the query percent-encode set and + * U+003F (?), U+0060 (`), U+007B ({), and U+007D (}). + */ + private static boolean pathPercentEncodeSet(int ch) { + return (queryPercentEncodeSet(ch) || ch == '?' || ch == '`' || ch == '{' || ch == '}'); + } + + /** + * The userinfo percent-encode set is the path percent-encode set and + * U+002F (/), U+003A (:), U+003B (;), U+003D (=), U+0040 (@), + * U+005B ([) to U+005E (^), inclusive, and U+007C (|). + */ + private static boolean userinfoPercentEncodeSet(int ch) { + return (pathPercentEncodeSet(ch) || ch == '/' || ch == ':' || ch == ';' || ch == '=' || ch == '@' || + (Integer.compareUnsigned(ch, '[') >= 0 && Integer.compareUnsigned(ch, '^') <= 0) || ch == '|'); + } + + private static boolean isC0Control(int ch) { + return (ch >= 0 && ch <= 0x1F); + } + + private static boolean isNewline(int ch) { + return (ch == '\r' || ch == '\n'); + } + + private static boolean isAsciiAlpha(int ch) { + return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); + } + + private static boolean containsOnlyAsciiDigits(CharSequence string) { + for (int i=0; i< string.length(); i++ ) { + int ch = codePointAt(string, i); + if (!isAsciiDigit(ch)) { + return false; + } + } + return true; + } + + private static boolean containsOnlyAscii(String string) { + for (int i = 0; i < string.length(); i++) { + int ch = string.codePointAt(i); + if (!isAsciiCodePoint(ch)) { + return false; + } + } + return true; + } + + private static boolean isAsciiCodePoint(int ch) { + // An ASCII code point is a code point in the range U+0000 NULL to U+007F DELETE, inclusive. + return (Integer.compareUnsigned(ch, 0) >= 0 && Integer.compareUnsigned(ch, 127) <= 0); + } + + private static boolean isAsciiDigit(int ch) { + return (ch >= '0' && ch <= '9'); + } + + private static boolean isAsciiAlphaNumeric(int ch) { + return (isAsciiAlpha(ch) || isAsciiDigit(ch)); + } + + private static boolean isAsciiHexDigit(int ch) { + return (isAsciiDigit(ch) || (ch >= 'A' && ch <= 'F') || (ch >= 'a' && ch <= 'f')); + } + + private static boolean isForbiddenDomain(int ch) { + return (isForbiddenHost(ch) || isC0Control(ch) || ch == '%' || ch == 0x7F); + } + + private static boolean isForbiddenHost(int ch) { + return (ch == 0x00 || ch == '\t' || isNewline(ch) || ch == ' ' || ch == '#' || + ch == '/' || ch == ':' || ch == '<' || ch == '>' || ch == '?' || ch == '@' || + ch == '[' || ch == '\\' || ch == ']' || ch == '^' || ch == '|'); + } + + private static boolean isNonCharacter(int ch) { + return ((ch >= 0xFDD0 && ch <= 0xFDEF) || ch == 0xFFFE || ch == 0xFFFF || ch == 0x1FFFE || ch == 0x1FFFF || + ch == 0x2FFFE || ch == 0x2FFFF || ch == 0x3FFFE || ch == 0x3FFFF || ch == 0x4FFFE || ch == 0x4FFFF || + ch == 0x5FFFE || ch == 0x5FFFF || ch == 0x6FFFE || ch == 0x6FFFF || ch == 0x7FFFE || ch == 0x7FFFF || + ch == 0x8FFFE || ch == 0x8FFFF || ch == 0x9FFFE || ch == 0x9FFFF || ch == 0xAFFFE || ch == 0xAFFFF || + ch == 0xBFFFE || ch == 0xBFFFF || ch == 0xCFFFE || ch == 0xCFFFF || ch == 0xDFFFE || ch == 0xDFFFF || + ch == 0xEFFFE || ch == 0xEFFFF || ch == 0xFFFFE || ch == 0xFFFFF || ch == 0x10FFFE || ch == 0x10FFFF); + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private static boolean isUrlCodePoint(int ch) { + return (isAsciiAlphaNumeric(ch) || + ch == '!' || ch == '$' || ch == '&' || ch == '\'' || ch == '(' || ch == ')' || + ch == '*' || ch == '+' || ch == ',' || ch == '-' || ch == '.' || ch == '/' || + ch == ':' || ch == ';' || ch == '=' || ch == '?' || ch == '@' || ch == '_' || ch == '~' || + (ch >= 0x00A0 && ch <= 0x10FFFD && !Character.isSurrogate((char) ch) && !isNonCharacter(ch))); + } + + private static boolean isSpecialScheme(String scheme) { + return ("ftp".equals(scheme) || "file".equals(scheme) || + "http".equals(scheme) || "https".equals(scheme) || + "ws".equals(scheme) || "wss".equals(scheme)); + } + + + private static int defaultPort(@Nullable String scheme) { + if (scheme != null) { + return switch (scheme) { + case "ftp" -> 21; + case "http", "ws" -> 80; + case "https", "wss" -> 443; + default -> -1; + }; + } + else { + return -1; + } + } + + private void append(String s) { + this.buffer.append(s); + } + + private void append(char ch) { + this.buffer.append(ch); + } + + private void append(int ch) { + this.buffer.appendCodePoint(ch); + } + + private void prepend(String s) { + this.buffer.insert(0, s); + } + + private void emptyBuffer() { + this.buffer.setLength(0); + } + + private int remaining(int deltaPos) { + int pos = this.pointer + deltaPos + 1; + return (pos < this.input.length() ? this.input.codePointAt(pos) : EOF); + } + + private static String percentDecode(String input) { + try { + return UriUtils.decode(input, StandardCharsets.UTF_8); + } + catch (IllegalArgumentException ex) { + throw new InvalidUrlException("Could not decode \"" + input + "\": " + ex.getMessage(), ex); + } + } + + @Nullable + private String percentEncode(int c, IntPredicate percentEncodeSet) { + if (this.encoding == null) { + return null; + } + else { + return percentEncode(Character.toString(c), percentEncodeSet); + } + } + + private String percentEncode(String input, IntPredicate percentEncodeSet) { + if (this.encoding == null) { + return input; + } + else { + byte[] bytes = input.getBytes(this.encoding); + boolean original = true; + for (byte b : bytes) { + if (percentEncodeSet.test(b)) { + original = false; + break; + } + } + if (original) { + return input; + } + StringBuilder output = new StringBuilder(); + for (byte b : bytes) { + if (!percentEncodeSet.test(b)) { + output.append((char)b); + } + else { + output.append('%'); + char hex1 = Character.toUpperCase(Character.forDigit((b >> 4) & 0xF, 16)); + char hex2 = Character.toUpperCase(Character.forDigit(b & 0xF, 16)); + output.append(hex1); + output.append(hex2); + } + } + return output.toString(); + } + } + + /** + * A single-dot URL path segment is a URL path segment that is "[/]." or + * an ASCII case-insensitive match for "[/]%2e". + */ + private static boolean isSingleDotPathSegment(StringBuilder b) { + int len = b.length(); + switch (len) { + case 1 -> { + int ch0 = b.codePointAt(0); + return ch0 == '.'; + } + case 2 -> { + int ch0 = b.codePointAt(0); + int ch1 = b.codePointAt(1); + return ch0 == '/' && ch1 == '.'; + } + case 3 -> { + // ASCII case-insensitive match for "%2e". + int ch0 = b.codePointAt(0); + int ch1 = b.codePointAt(1); + int ch2 = b.codePointAt(2); + return ch0 == '%' && ch1 == '2' && (ch2 == 'e' || ch2 == 'E'); + } + case 4 -> { + // ASCII case-insensitive match for "/%2e". + int ch0 = b.codePointAt(0); + int ch1 = b.codePointAt(1); + int ch2 = b.codePointAt(2); + int ch3 = b.codePointAt(3); + return ch0 == '/' && ch1 == '%' && ch2 == '2' && (ch3 == 'e' || ch3 == 'E'); + } + default -> { + return false; + } + } + } + + /** + * A double-dot URL path segment is a URL path segment that is "[/].." or + * an ASCII case-insensitive match for "/.%2e", "/%2e.", or "/%2e%2e". + */ + private static boolean isDoubleDotPathSegment(StringBuilder b) { + int len = b.length(); + switch (len) { + case 2 -> { + int ch0 = b.codePointAt(0); + int ch1 = b.codePointAt(1); + return (ch0 == '.' && ch1 == '.'); + } + case 3 -> { + int ch0 = b.codePointAt(0); + int ch1 = b.codePointAt(1); + int ch2 = b.codePointAt(2); + return (ch0 == '/' && ch1 == '.' && ch2 == '.'); + } + case 4 -> { + int ch0 = b.codePointAt(0); + int ch1 = b.codePointAt(1); + int ch2 = b.codePointAt(2); + int ch3 = b.codePointAt(3); + // case-insensitive match for ".%2e" or "%2e." + return (ch0 == '.' && ch1 == '%' && ch2 == '2' && (ch3 == 'e' || ch3 == 'E') || + (ch0 == '%' && ch1 == '2' && (ch2 == 'e' || ch2 == 'E') && ch3 == '.')); + } + case 5 -> { + int ch0 = b.codePointAt(0); + int ch1 = b.codePointAt(1); + int ch2 = b.codePointAt(2); + int ch3 = b.codePointAt(3); + int ch4 = b.codePointAt(4); + // case-insensitive match for "/.%2e" or "/%2e." + return (ch0 == '/' && + (ch1 == '.' && ch2 == '%' && ch3 == '2' && (ch4 == 'e' || ch4 == 'E') || + (ch1 == '%' && ch2 == '2' && (ch3 == 'e' || ch3 == 'E') && ch4 == '.'))); + } + case 6 -> { + int ch0 = b.codePointAt(0); + int ch1 = b.codePointAt(1); + int ch2 = b.codePointAt(2); + int ch3 = b.codePointAt(3); + int ch4 = b.codePointAt(4); + int ch5 = b.codePointAt(5); + // case-insensitive match for "%2e%2e". + return (ch0 == '%' && ch1 == '2' && (ch2 == 'e' || ch2 == 'E') && + ch3 == '%' && ch4 == '2' && (ch5 == 'e' || ch5 == 'E')); + } + case 7 -> { + int ch0 = b.codePointAt(0); + int ch1 = b.codePointAt(1); + int ch2 = b.codePointAt(2); + int ch3 = b.codePointAt(3); + int ch4 = b.codePointAt(4); + int ch5 = b.codePointAt(5); + int ch6 = b.codePointAt(6); + // case-insensitive match for "/%2e%2e". + return (ch0 == '/' && ch1 == '%' && ch2 == '2' && (ch3 == 'e' || ch3 == 'E') && + ch4 == '%' && ch5 == '2' && (ch6 == 'e' || ch6 == 'E')); + } + default -> { + return false; + } + } + } + + + /** + * A Windows drive letter is two code points, of which the first is an ASCII alpha + * and the second is either U+003A {@code (:)} or U+007C {@code (|)}. + * A normalized Windows drive letter is a Windows drive letter of which + * the second code point is U+003A {@code (:)}. + */ + private static boolean isWindowsDriveLetter(CharSequence input, boolean normalized) { + if (input.length() != 2) { + return false; + } + return isWindowsDriveLetterInternal(input, normalized); + } + + /** + * A string starts with a Windows drive letter if all the following are true: + * its length is greater than or equal to 2 + * its first two code points are a Windows drive letter + * its length is 2 or its third code point is U+002F (/), U+005C (\), U+003F (?), or U+0023 (#). + */ + private static boolean startsWithWindowsDriveLetter(String input) { + int len = input.length(); + if (len < 2) { + return false; + } + if (!isWindowsDriveLetterInternal(input, false)) { + return false; + } + if (len == 2) { + return true; + } + else { + int ch2 = input.codePointAt(2); + return (ch2 == '/' || ch2 == '\\' || ch2 == '?' || ch2 == '#'); + } + } + + private static boolean isWindowsDriveLetterInternal(CharSequence s, boolean normalized) { + int ch0 = codePointAt(s, 0); + if (!isAsciiAlpha(ch0)) { + return false; + } + else { + int ch1 = codePointAt(s, 1); + if (normalized) { + return ch1 == ':'; + } + else { + return ch1 == ':' || ch1 == '|'; + } + } + } + + private static int codePointAt(CharSequence s, int index) { + if (s instanceof String string) { + return string.codePointAt(index); + } + else if (s instanceof StringBuilder builder) { + return builder.codePointAt(index); + } + else { + throw new IllegalStateException(); + } + } + + + private enum State { + + SCHEME_START { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If c is an ASCII alpha, append c, lowercased, to buffer, and set state to scheme state. + if (isAsciiAlpha(c)) { + p.append(p.openCurlyBracketCount == 0 ? Character.toLowerCase((char) c) : c); + p.setState(SCHEME); + } + // EXTRA: if c is '{', append to buffer and continue as SCHEME + else if (c == '{') { + p.openCurlyBracketCount++; + p.append(c); + p.setState(SCHEME); + } + // Otherwise, if state override is not given, + // set state to no scheme state and decrease pointer by 1. + else if (p.stateOverride == null) { + p.setState(NO_SCHEME); + p.pointer--; + } + // Otherwise, return failure. + else { + p.failure(null); + } + } + }, + SCHEME { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If c is an ASCII alphanumeric, U+002B (+), U+002D (-), or U+002E (.), append c, lowercased, to buffer. + if (isAsciiAlphaNumeric(c) || (c == '+' || c == '-' || c == '.')) { + p.append(p.openCurlyBracketCount == 0 ? Character.toLowerCase((char) c) : c); + } + // Otherwise, if c is U+003A (:), then: + else if (c == ':') { + // If state override is given, then: + if (p.stateOverride != null) { + boolean urlSpecialScheme = url.isSpecial(); + String bufferString = p.buffer.toString(); + boolean bufferSpecialScheme = isSpecialScheme(bufferString); + // If url’s scheme is a special scheme and buffer is not a special scheme, then return. + if (urlSpecialScheme && !bufferSpecialScheme) { + return; + } + // If url’s scheme is not a special scheme and buffer is a special scheme, then return. + if (!urlSpecialScheme && bufferSpecialScheme) { + return; + } + // If url includes credentials or has a non-null port, and buffer is "file", then return. + if ((url.includesCredentials() || url.port() != null) && "file".equals(bufferString)) { + return; + } + // If url’s scheme is "file" and its host is an empty host, then return. + if ("file".equals(url.scheme()) && + (url.host() == null || url.host() == EmptyHost.INSTANCE)) { + return; + } + } + // Set url’s scheme to buffer. + url.scheme = p.buffer.toString(); + // If state override is given, then: + if (p.stateOverride != null) { + // If url’s port is url’s scheme’s default port, then set url’s port to null. + if (url.port instanceof IntPort intPort && intPort.value() == defaultPort(url.scheme)) { + url.port = null; + // Return. + p.stopMainLoop = true; + return; + } + } + // Set buffer to the empty string. + p.emptyBuffer(); + // If url’s scheme is "file", then: + if (url.scheme.equals("file")) { + // If remaining does not start with "//", + // special-scheme-missing-following-solidus validation error. + if (p.validate() && (p.remaining(0) != '/' || p.remaining(1) != '/')) { + p.validationError("\"file\" scheme not followed by \"//\"."); + } + // Set state to file state. + p.setState(FILE); + } + // Otherwise, if url is special, base is non-null, and base’s scheme is url’s scheme: + else if (url.isSpecial() && p.base != null && p.base.scheme().equals(url.scheme)) { + // Assert: base is special (and therefore does not have an opaque path). + Assert.state(!p.base.path().isOpaque(), "Opaque path not expected"); + // Set state to special relative or authority state. + p.setState(SPECIAL_RELATIVE_OR_AUTHORITY); + } + // Otherwise, if url is special, set state to special authority slashes state. + else if (url.isSpecial()) { + p.setState(SPECIAL_AUTHORITY_SLASHES); + } + // Otherwise, if remaining starts with an U+002F (/), + // set state to path or authority state and increase pointer by 1. + else if (p.remaining(0) == '/') { + p.setState(PATH_OR_AUTHORITY); + p.pointer++; + } + // Otherwise, set url’s path to the empty string and set state to opaque path state. + else { + url.path = new PathSegment(""); + p.setState(OPAQUE_PATH); + } + } + // EXTRA: if c is within URI variable, keep appending + else if (p.processCurlyBrackets(c)) { + p.append(c); + } + // Otherwise, if state override is not given, set buffer to the empty string, + // state to no scheme state, and start over (from the first code point in input). + else if (p.stateOverride == null) { + p.emptyBuffer(); + p.setState(NO_SCHEME); + p.pointer = -1; + } + // Otherwise, return failure. + else { + p.failure(null); + } + + } + }, + NO_SCHEME { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If base is null, or base has an opaque path and c is not U+0023 (#), + // missing-scheme-non-relative-URL validation error, return failure. + if (p.base == null || p.base.path().isOpaque() && c != '#') { + p.failure("The input is missing a scheme, because it does not begin with an ASCII alpha \"" + + (c != EOF ? Character.toString(c) : "") + "\", and no base URL was provided."); + } + // Otherwise, if base has an opaque path and c is U+0023 (#), + // set url’s scheme to base’s scheme, url’s path to base’s path, + // url’s query to base’s query, url’s fragment to the empty string, + // and set state to fragment state. + else if (p.base.path().isOpaque() && c == '#') { + url.scheme = p.base.scheme(); + url.path = p.base.path(); + url.query = p.base.query; + url.fragment = new StringBuilder(); + p.setState(FRAGMENT); + } + // Otherwise, if base’s scheme is not "file", + // set state to relative state and decrease pointer by 1. + else if (!"file".equals(p.base.scheme())) { + p.setState(RELATIVE); + p.pointer--; + } + // Otherwise, set state to file state and decrease pointer by 1. + else { + p.setState(FILE); + p.pointer--; + } + } + }, + SPECIAL_RELATIVE_OR_AUTHORITY { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If c is U+002F (/) and remaining starts with U+002F (/), + // then set state to special authority ignore slashes state and + // increase pointer by 1. + if (c == '/' && p.remaining(0) == '/') { + p.setState(SPECIAL_AUTHORITY_IGNORE_SLASHES); + p.pointer++; + } + // Otherwise, special-scheme-missing-following-solidus validation error, + // set state to relative state and decrease pointer by 1. + else { + if (p.validate()) { + p.validationError("The input’s scheme is not followed by \"//\"."); + } + p.setState(RELATIVE); + p.pointer--; + } + } + }, + PATH_OR_AUTHORITY { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If c is U+002F (/), then set state to authority state. + if (c == '/') { + p.setState(AUTHORITY); + } + // Otherwise, set state to path state, and decrease pointer by 1. + else { + p.setState(PATH); + p.pointer--; + } + } + }, + RELATIVE { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // Assert: base’s scheme is not "file". + Assert.state(p.base != null && !"file".equals(p.base.scheme()), + "Base scheme not provided or supported"); + // Set url’s scheme to base’s scheme. + url.scheme = p.base.scheme; + // If c is U+002F (/), then set state to relative slash state. + if (c == '/') { + // EXTRA : append '/' to let the path segment start with / + p.append('/'); + p.setState(RELATIVE_SLASH); + } + // Otherwise, if url is special and c is U+005C (\), + // invalid-reverse-solidus validation error, set state to relative slash state. + else if (url.isSpecial() && c == '\\') { + if (p.validate()) { + p.validationError("URL uses \\ instead of /."); + } + // EXTRA : append '/' to let the path segment start with / + p.append('/'); + p.setState(RELATIVE_SLASH); + } + // Otherwise + else { + // Set url’s username to base’s username, url’s password to base’s password, + // url’s host to base’s host, url’s port to base’s port, + // url’s path to a clone of base’s path, and url’s query to base’s query. + url.username = ((p.base.username != null) ? new StringBuilder(p.base.username) : null); + url.password = ((p.base.password != null) ? new StringBuilder(p.base.password) : null); + url.host = p.base.host(); + url.port = p.base.port(); + url.path = p.base.path().clone(); + url.query = p.base.query; + // If c is U+003F (?), then set url’s query to the empty string, and state to query state. + if (c == '?') { + url.query = new StringBuilder(); + p.setState(QUERY); + } + // Otherwise, if c is U+0023 (#), set url’s fragment to the empty string and state to fragment state. + else if (c == '#') { + url.fragment = new StringBuilder(); + p.setState(FRAGMENT); + } + // Otherwise, if c is not the EOF code point: + else if (c != EOF) { + // Set url’s query to null. + url.query = null; + // Shorten url’s path. + url.shortenPath(); + // Set state to path state and decrease pointer by 1. + p.setState(PATH); + p.pointer--; + } + } + } + }, + RELATIVE_SLASH { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If url is special and c is U+002F (/) or U+005C (\), then: + if (url.isSpecial() && (c == '/' || c == '\\')) { + // If c is U+005C (\), invalid-reverse-solidus validation error. + if (p.validate() && c == '\\') { + p.validationError("URL uses \\ instead of /."); + } + // Set state to special authority ignore slashes state. + p.setState(SPECIAL_AUTHORITY_IGNORE_SLASHES); + } + // Otherwise, if c is U+002F (/), then set state to authority state. + else if (c == '/') { + // EXTRA: empty buffer to remove appended slash, since this is not a path + p.emptyBuffer(); + p.setState(AUTHORITY); + } + // Otherwise, set url’s username to base’s username, url’s password to base’s password, + // url’s host to base’s host, url’s port to base’s port, state to path state, + // and then, decrease pointer by 1. + else { + Assert.state(p.base != null, "No base URL available"); + url.username = (p.base.username != null) ? new StringBuilder(p.base.username) : null; + url.password = (p.base.password != null) ? new StringBuilder(p.base.password) : null; + url.host = p.base.host(); + url.port = p.base.port(); + p.setState(PATH); + p.pointer--; + } + + } + }, + SPECIAL_AUTHORITY_SLASHES { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If c is U+002F (/) and remaining starts with U+002F (/), + // then set state to special authority ignore slashes state and + // increase pointer by 1. + if (c == '/' && p.remaining(0) == '/') { + p.setState(SPECIAL_AUTHORITY_IGNORE_SLASHES); + p.pointer++; + } + // Otherwise, special-scheme-missing-following-solidus validation error, + // set state to special authority ignore slashes state and decrease pointer by 1. + else { + if (p.validate()) { + p.validationError("Scheme \"" + url.scheme + "\" not followed by \"//\"."); + } + p.setState(SPECIAL_AUTHORITY_IGNORE_SLASHES); + p.pointer--; + } + } + }, + SPECIAL_AUTHORITY_IGNORE_SLASHES { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If c is neither U+002F (/) nor U+005C (\), + // then set state to authority state and decrease pointer by 1. + if (c != '/' && c != '\\') { + p.setState(AUTHORITY); + p.pointer--; + } + // Otherwise, special-scheme-missing-following-solidus validation error. + else { + if (p.validate()) { + p.validationError("Scheme \"" + url.scheme + "\" not followed by \"//\"."); + } + } + } + }, + AUTHORITY { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If c is U+0040 (@), then: + if (c == '@') { + // Invalid-credentials validation error. + if (p.validate()) { + p.validationError("Invalid credentials"); + } + // If atSignSeen is true, then prepend "%40" to buffer. + if (p.atSignSeen) { + p.prepend("%40"); + } + // Set atSignSeen to true. + p.atSignSeen = true; + + int bufferLen = p.buffer.length(); + // For each codePoint in buffer: + for (int i = 0; i < bufferLen; i++) { + int codePoint = p.buffer.codePointAt(i); + // If codePoint is U+003A (:) and passwordTokenSeen is false, + // then set passwordTokenSeen to true and continue. + if (codePoint == ':' && !p.passwordTokenSeen) { + p.passwordTokenSeen = true; + continue; + } + // Let encodedCodePoints be the result of running UTF-8 percent-encode codePoint + // using the userinfo percent-encode set. + String encodedCodePoints = p.percentEncode(codePoint, WhatWgUrlParser::userinfoPercentEncodeSet); + // If passwordTokenSeen is true, then append encodedCodePoints to url’s password. + if (p.passwordTokenSeen) { + if (encodedCodePoints != null) { + url.appendToPassword(encodedCodePoints); + } + else { + url.appendToPassword(codePoint); + } + } + // Otherwise, append encodedCodePoints to url’s username. + else { + if (encodedCodePoints != null) { + url.appendToUsername(encodedCodePoints); + } + else { + url.appendToUsername(codePoint); + } + } + } + // Set buffer to the empty string. + p.emptyBuffer(); + } + // Otherwise, if one of the following is true: + // - c is the EOF code point, U+002F (/), U+003F (?), or U+0023 (#) + // - url is special and c is U+005C (\) + else if ((c == EOF || c == '/' || c == '?' || c == '#') || (url.isSpecial() && c == '\\')) { + // If atSignSeen is true and buffer is the empty string, + // host-missing validation error, return failure. + if (p.atSignSeen && p.buffer.isEmpty()) { + p.failure("Missing host."); + } + // Decrease pointer by buffer’s code point length + 1, + // set buffer to the empty string, and set state to host state. + p.pointer -= p.buffer.length() + 1; + p.emptyBuffer(); + p.setState(HOST); + } + // Otherwise, append c to buffer. + else { + p.append(c); + } + } + }, + HOST { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If state override is given and url’s scheme is "file", + // then decrease pointer by 1 and set state to file host state. + if (p.stateOverride != null && "file".equals(url.scheme())) { + p.pointer--; + p.setState(FILE_HOST); + } + // Otherwise, if c is U+003A (:) and insideBrackets is false, then: + else if (c == ':' && !p.insideBrackets) { + // If buffer is the empty string, host-missing validation error, return failure. + if (p.buffer.isEmpty()) { + p.failure("Missing host."); + } + // If state override is given and state override is hostname state, then return. + if (p.stateOverride == HOST) { + p.stopMainLoop = true; + return; + } + // Let host be the result of host parsing buffer with url is not special. + // Set url’s host to host, buffer to the empty string, and state to port state. + url.host = Host.parse(p.buffer.toString(), !url.isSpecial(), p); + p.emptyBuffer(); + p.setState(PORT); + } + // Otherwise, if one of the following is true: + // - c is the EOF code point, U+002F (/), U+003F (?), or U+0023 (#) + // - url is special and c is U+005C (\) + else if ( (c == EOF || c == '/' || c == '?' || c == '#') || + (url.isSpecial() && c == '\\')) { + // then decrease pointer by 1, and then: + p.pointer--; + // If url is special and buffer is the empty string, + // host-missing validation error, return failure. + if (url.isSpecial() && p.buffer.isEmpty()) { + p.failure("The input has a special scheme, but does not contain a host."); + } + // Otherwise, if state override is given, buffer is the empty string, + // and either url includes credentials or url’s port is non-null, return. + else if (p.stateOverride != null && p.buffer.isEmpty() && + (url.includesCredentials() || url.port() != null )) { + p.stopMainLoop = true; + return; + } + // EXTRA: if buffer is not empty + if (!p.buffer.isEmpty()) { + // Let host be the result of host parsing buffer with url is not special. + // Set url’s host to host, buffer to the empty string, and state to path start state. + url.host = Host.parse(p.buffer.toString(), !url.isSpecial(), p); + } + else { + url.host = EmptyHost.INSTANCE; + } + p.emptyBuffer(); + p.setState(PATH_START); + // If state override is given, then return. + if (p.stateOverride != null) { + p.stopMainLoop = true; + } + } + // Otherwise: + else { + // If c is U+005B ([), then set insideBrackets to true. + if (c == '[') { + p.insideBrackets = true; + } + // If c is U+005D (]), then set insideBrackets to false. + else if (c == ']') { + p.insideBrackets = false; + } + // Append c to buffer. + p.append(c); + } + } + }, + PORT { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If c is an ASCII digit, append c to buffer. + if (isAsciiDigit(c)) { + p.append(c); + } + // Otherwise, if one of the following is true: + // - c is the EOF code point, U+002F (/), U+003F (?), or U+0023 (#) + // - url is special and c is U+005C (\) + // - state override is given + else if (c == EOF || c == '/' || c == '?' || c == '#' || + (url.isSpecial() && c == '\\') || + (p.stateOverride != null)) { + // If buffer is not the empty string, then: + if (!p.buffer.isEmpty()) { + // EXTRA: if buffer contains only ASCII digits, then + if (containsOnlyAsciiDigits(p.buffer)) { + try { + // Let port be the mathematical integer value that is represented + // by buffer in radix-10 using ASCII digits for digits with values 0 through 9. + int port = Integer.parseInt(p.buffer, 0, p.buffer.length(), 10); + // If port is greater than 2^16 − 1, + // port-out-of-range validation error, return failure. + if (port > MAX_PORT) { + p.failure("Port \"" + port + "\" is out of range"); + } + int defaultPort = defaultPort(url.scheme); + // Set url’s port to null, if port is url’s scheme’s default port; otherwise to port. + if (defaultPort != -1 && port == defaultPort) { + url.port = null; + } + else { + url.port = new IntPort(port); + } + } + catch (NumberFormatException ex) { + p.failure(ex.getMessage()); + } + } + // EXTRA: otherwise, set url's port to buffer + else { + url.port = new StringPort(p.buffer.toString()); + } + // Set buffer to the empty string. + p.emptyBuffer(); + } + // If state override is given, then return. + if (p.stateOverride != null) { + p.stopMainLoop = true; + return; + } + // Set state to path start state and decrease pointer by 1. + p.setState(PATH_START); + p.pointer--; + } + // EXTRA: if c is within URI variable, keep appending + else if (p.processCurlyBrackets(c)) { + p.append(c); + } + // Otherwise, port-invalid validation error, return failure. + else { + p.failure("Invalid port: \"" + Character.toString(c) + "\""); + } + } + }, + FILE { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // Set url’s scheme to "file". + url.scheme = "file"; + // Set url’s host to the empty string. + url.host = EmptyHost.INSTANCE; + // If c is U+002F (/) or U+005C (\), then: + if (c == '/' || c == '\\') { + // If c is U+005C (\), invalid-reverse-solidus validation error. + if (p.validate() && c == '\\') { + p.validationError("URL uses \\ instead of /."); + } + // Set state to file slash state. + p.setState(FILE_SLASH); + } + // Otherwise, if base is non-null and base’s scheme is "file": + else if (p.base != null && p.base.scheme().equals("file")) { + // Set url’s host to base’s host, url’s path to a clone of base’s path, + // and url’s query to base’s query. + url.host = p.base.host; + url.path = p.base.path().clone(); + url.query = p.base.query; + // If c is U+003F (?), then set url’s query to the empty string and state to query state. + if (c == '?') { + url.query = new StringBuilder(); + p.setState(QUERY); + } + // Otherwise, if c is U+0023 (#), set url’s fragment to + // the empty string and state to fragment state. + else if (c == '#') { + url.fragment = new StringBuilder(); + p.setState(FRAGMENT); + } + // Otherwise, if c is not the EOF code point: + else if (c != EOF) { + // Set url’s query to null. + url.query = null; + // If the code point substring from pointer to the end of input does not start with + // a Windows drive letter, then shorten url’s path. + String substring = p.input.substring(p.pointer); + if (!startsWithWindowsDriveLetter(substring)) { + url.shortenPath(); + } + // Otherwise: + else { + // File-invalid-Windows-drive-letter validation error. + if (p.validate()) { + p.validationError("The input is a relative-URL string that starts with " + + "a Windows drive letter and the base URL’s scheme is \"file\"."); + } + // Set url’s path to « ». + url.path = new PathSegments(); + } + // Set state to path state and decrease pointer by 1. + p.setState(PATH); + p.pointer--; + } + } + // Otherwise, set state to path state, and decrease pointer by 1. + else { + p.setState(PATH); + p.pointer--; + } + } + }, + FILE_SLASH { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If c is U+002F (/) or U+005C (\), then: + if (c == '/' || c == '\\') { + // If c is U+005C (\), invalid-reverse-solidus validation error. + if (p.validate() && c == '\\') { + p.validationError("URL uses \\ instead of /."); + } + // Set state to file host state. + p.setState(FILE_HOST); + } + // Otherwise: + else { + // If base is non-null and base’s scheme is "file", then: + if (p.base != null && p.base.scheme.equals("file")) { + // Set url’s host to base’s host. + url.host = p.base.host; + // If the code point substring from pointer to the end of input does not start with + // a Windows drive letter and base’s path[0] is a normalized Windows drive letter, + // then append base’s path[0] to url’s path. + String substring = p.input.substring(p.pointer); + if (!startsWithWindowsDriveLetter(substring) && + p.base.path instanceof PathSegments basePath && + !basePath.isEmpty() && + isWindowsDriveLetter(basePath.get(0), true)) { + url.path.append(basePath.get(0)); + } + } + // Set state to path state, and decrease pointer by 1. + p.setState(PATH); + p.pointer--; + } + } + }, + FILE_HOST { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If c is the EOF code point, U+002F (/), U+005C (\), U+003F (?), or U+0023 (#), + // then decrease pointer by 1 and then: + if (c == EOF || c == '/' || c == '\\' || c == '?' || c == '#') { + p.pointer--; + // If state override is not given and buffer is a Windows drive letter, + // file-invalid-Windows-drive-letter-host validation error, set state to path state. + if (p.stateOverride == null && isWindowsDriveLetter(p.buffer, false)) { + p.validationError("A file: URL’s host is a Windows drive letter."); + p.setState(PATH); + } + // Otherwise, if buffer is the empty string, then: + else if (p.buffer.isEmpty()) { + // Set url’s host to the empty string. + url.host = EmptyHost.INSTANCE; + // If state override is given, then return. + if (p.stateOverride != null) { + p.stopMainLoop = true; + return; + } + // Set state to path start state. + p.setState(PATH_START); + } + // Otherwise, run these steps: + else { + // Let host be the result of host parsing buffer with url is not special. + Host host = Host.parse(p.buffer.toString(), !url.isSpecial(), p); + // If host is "localhost", then set host to the empty string. + if (host instanceof Domain domain && domain.domain().equals("localhost")) { + host = EmptyHost.INSTANCE; + } + // Set url’s host to host. + url.host = host; + // If state override is given, then return. + if (p.stateOverride != null) { + p.stopMainLoop = true; + return; + } + // Set buffer to the empty string and state to path start state. + p.emptyBuffer(); + p.setState(PATH_START); + } + } + // Otherwise, append c to buffer. + else { + p.append(c); + } + } + }, + PATH_START { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If url is special, then: + if (url.isSpecial()) { + // If c is U+005C (\), invalid-reverse-solidus validation error. + if (p.validate() && c == '\\') { + p.validationError("URL uses \"\\\" instead of \"/\""); + } + // Set state to path state. + p.setState(PATH); + // If c is neither U+002F (/) nor U+005C (\), then decrease pointer by 1. + if (c != '/' && c != '\\') { + p.pointer--; + } + else { + p.append('/'); + } + } + // Otherwise, if state override is not given and if c is U+003F (?), + // set url’s query to the empty string and state to query state. + else if (p.stateOverride == null && c == '?') { + url.query = new StringBuilder(); + p.setState(QUERY); + } + // Otherwise, if state override is not given and if c is U+0023 (#), + // set url’s fragment to the empty string and state to fragment state. + else if (p.stateOverride == null && c =='#') { + url.fragment = new StringBuilder(); + p.setState(FRAGMENT); + } + // Otherwise, if c is not the EOF code point: + else if (c != EOF) { + // Set state to path state. + p.setState(PATH); + // If c is not U+002F (/), then decrease pointer by 1. + if (c != '/') { + p.pointer--; + } + // EXTRA: otherwise append '/' to let the path segment start with / + else { + p.append('/'); + } + } + // Otherwise, if state override is given and url’s host is null, + // append the empty string to url’s path. + else if (p.stateOverride != null && url.host() == null) { + url.path().append(""); + } + } + }, + PATH { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If one of the following is true: + // - c is the EOF code point or U+002F (/) + // - url is special and c is U+005C (\) + // - state override is not given and c is U+003F (?) or U+0023 (#) + // then: + if (c == EOF || c == '/' || + (url.isSpecial() && c == '\\') || + (p.stateOverride == null && (c == '?' || c == '#'))) { + // If url is special and c is U+005C (\), invalid-reverse-solidus validation error. + if (p.validate() && url.isSpecial() && c == '\\') { + p.validationError("URL uses \"\\\" instead of \"/\""); + } + // If buffer is a double-dot URL path segment, then: + if (isDoubleDotPathSegment(p.buffer)) { + // Shorten url’s path. + url.shortenPath(); + // If neither c is U+002F (/), nor url is special and c is U+005C (\), + // append the empty string to url’s path. + if (c != '/' && !(url.isSpecial() && c == '\\')) { + url.path.append(""); + } + } + else { + boolean singlePathSegment = isSingleDotPathSegment(p.buffer); + // Otherwise, if buffer is a single-dot URL path segment and if neither c is U+002F (/), + // nor url is special and c is U+005C (\), append the empty string to url’s path. + if (singlePathSegment && c != '/' && !(url.isSpecial() && c == '\\')) { + url.path.append(""); + } + // Otherwise, if buffer is not a single-dot URL path segment, then: + else if (!singlePathSegment) { + // If url’s scheme is "file", url’s path is empty, and buffer is + // a Windows drive letter, then replace the second code point in buffer with U+003A (:). + if ("file".equals(url.scheme) && url.path.isEmpty() && isWindowsDriveLetter(p.buffer, false)) { + p.buffer.setCharAt(1, ':'); + } + // Append buffer to url’s path. + url.path.append(p.buffer.toString()); + } + } + // Set buffer to the empty string. + p.emptyBuffer(); + if ( c == '/' || url.isSpecial() && c == '\\') { + p.append('/'); + } + // If c is U+003F (?), then set url’s query to the empty string and state to query state. + if (c == '?') { + url.query = new StringBuilder(); + p.setState(QUERY); + } + // If c is U+0023 (#), then set url’s fragment to the empty string and state to fragment state. + if (c == '#') { + url.fragment = new StringBuilder(); + p.setState(FRAGMENT); + } + } + // Otherwise, run these steps: + else { + if (p.validate()) { + // If c is not a URL code point and not U+0025 (%), invalid-URL-unit validation error. + if (!isUrlCodePoint(c) && c != '%') { + p.validationError("Invalid URL Unit: \"" + (char) c + "\""); + } + // If c is U+0025 (%) and remaining does not start with two ASCII hex digits, + // invalid-URL-unit validation error. + else if (c == '%' && + (p.pointer >= p.input.length() - 2 || + !isAsciiHexDigit(p.input.codePointAt(p.pointer + 1)) || + !isAsciiHexDigit(p.input.codePointAt(p.pointer + 2)))) { + p.validationError("Invalid URL Unit: \"" + (char) c + "\""); + } + } + // UTF-8 percent-encode c using the path percent-encode set and append the result to buffer. + String encoded = p.percentEncode(c, WhatWgUrlParser::pathPercentEncodeSet); + if (encoded != null) { + p.append(encoded); + } + else { + p.append(c); + } + } + } + }, + OPAQUE_PATH { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If c is U+003F (?), then set url’s query to the empty string and state to query state. + if (c == '?') { + url.query = new StringBuilder(); + p.setState(QUERY); + } + // Otherwise, if c is U+0023 (#), then set url’s fragment to + // the empty string and state to fragment state. + else if (c == '#') { + url.fragment = new StringBuilder(); + p.setState(FRAGMENT); + } + // Otherwise: + else { + if (p.validate()) { + // If c is not the EOF code point, not a URL code point, and not U+0025 (%), + // invalid-URL-unit validation error. + if (c != EOF && !isUrlCodePoint(c) && c != '%') { + p.validationError("Invalid URL Unit: \"" + (char) c + "\""); + } + // If c is U+0025 (%) and remaining does not start with two ASCII hex digits, + // invalid-URL-unit validation error. + else if (c == '%' && + (p.pointer >= p.input.length() - 2 || + !isAsciiHexDigit(p.input.codePointAt(p.pointer + 1)) || + !isAsciiHexDigit(p.input.codePointAt(p.pointer + 2)))) { + p.validationError("Invalid URL Unit: \"" + (char) c + "\""); + } + } + // If c is not the EOF code point, UTF-8 percent-encode c using + // the C0 control percent-encode set and append the result to url’s path. + if (c != EOF) { + String encoded = p.percentEncode(c, WhatWgUrlParser::c0ControlPercentEncodeSet); + if (encoded != null) { + url.path.append(encoded); + } + else { + url.path.append(c); + } + } + } + } + }, + QUERY { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If encoding is not UTF-8 and one of the following is true: + // - url is not special + // - url’s scheme is "ws" or "wss" + // then set encoding to UTF-8. + if (p.encoding != null && + !StandardCharsets.UTF_8.equals(p.encoding) && + (!url.isSpecial() || "ws".equals(url.scheme) || "wss".equals(url.scheme))) { + p.encoding = StandardCharsets.UTF_8; + } + // If one of the following is true: + // - state override is not given and c is U+0023 (#) + // - c is the EOF code point + if ( (p.stateOverride == null && c == '#') || c == EOF) { + // Let queryPercentEncodeSet be the special-query percent-encode set if url is special; + // otherwise the query percent-encode set. + IntPredicate queryPercentEncodeSet = (url.isSpecial() ? + WhatWgUrlParser::specialQueryPercentEncodeSet : WhatWgUrlParser::queryPercentEncodeSet); + // Percent-encode after encoding, with encoding, buffer, and queryPercentEncodeSet, + // and append the result to url’s query. + String encoded = p.percentEncode(p.buffer.toString(), queryPercentEncodeSet); + Assert.state(url.query != null, "Url's query should not be null"); + url.query.append(encoded); + // Set buffer to the empty string. + p.emptyBuffer(); + // If c is U+0023 (#), then set url’s fragment to the empty string and state to fragment state. + if (c == '#') { + url.fragment = new StringBuilder(); + p.setState(FRAGMENT); + } + } + // Otherwise, if c is not the EOF code point: + else { + if (p.validate()) { + // If c is not a URL code point and not U+0025 (%), invalid-URL-unit validation error. + if (!isUrlCodePoint(c) && c != '%') { + p.validationError("Invalid URL Unit: \"" + (char) c + "\""); + } + // If c is U+0025 (%) and remaining does not start with two ASCII hex digits, + // invalid-URL-unit validation error. + else if (c == '%' && + (p.pointer >= p.input.length() - 2 || + !isAsciiHexDigit(p.input.codePointAt(p.pointer + 1)) || + !isAsciiHexDigit(p.input.codePointAt(p.pointer + 2)))) { + p.validationError("Invalid URL Unit: \"" + (char) c + "\""); + } + } + // Append c to buffer. + p.append(c); + } + } + }, + FRAGMENT { + @Override + public void handle(int c, UrlRecord url, WhatWgUrlParser p) { + // If c is not the EOF code point, then: + if (c != EOF) { + if (p.validate()) { + // If c is not a URL code point and not U+0025 (%), invalid-URL-unit validation error. + if (!isUrlCodePoint(c) && c != '%') { + p.validationError("Invalid URL Unit: \"" + (char) c + "\""); + } + // If c is U+0025 (%) and remaining does not start with two ASCII hex digits, + // invalid-URL-unit validation error. + else if (c == '%' && + (p.pointer >= p.input.length() - 2 || + !isAsciiHexDigit(p.input.codePointAt(p.pointer + 1)) || + !isAsciiHexDigit(p.input.codePointAt(p.pointer + 2)))) { + p.validationError("Invalid URL Unit: \"" + (char) c + "\""); + } + } + // UTF-8 percent-encode c using the fragment percent-encode set and + // append the result to url’s fragment. + String encoded = p.percentEncode(c, WhatWgUrlParser::fragmentPercentEncodeSet); + Assert.state(url.fragment != null, "Url's fragment should not be null"); + if (encoded != null) { + url.fragment.append(encoded); + } + else { + url.fragment.appendCodePoint(c); + } + } + } + }; + + public abstract void handle(int c, UrlRecord url, WhatWgUrlParser p); + + } + + + /** + * A URL is a struct that represents a universal identifier. + * To disambiguate from a valid URL string it can also be referred to as a + * URL record. + */ + static final class UrlRecord { + + private String scheme = ""; + + @Nullable + private StringBuilder username = null; + + @Nullable + private StringBuilder password = null; + + @Nullable + private Host host = null; + + @Nullable + private Port port = null; + + private Path path = new PathSegments(); + + @Nullable + private StringBuilder query = null; + + @Nullable + private StringBuilder fragment = null; + + public UrlRecord() { + } + + /** + * A URL is special if its scheme is a special scheme. A URL is not special if its scheme is not a special scheme. + */ + public boolean isSpecial() { + return isSpecialScheme(this.scheme); + } + + /** + * A URL includes credentials if its username or password is not the empty string. + */ + public boolean includesCredentials() { + return (this.username != null && !this.username.isEmpty() || + this.password != null && !this.password.isEmpty()); + } + + /** + * A URL has an opaque path if its path is a URL path segment. + */ + public boolean hasOpaquePath() { + return path().isOpaque(); + } + + + /** + * A URL’s scheme is an ASCII string that identifies the type of URL and + * can be used to dispatch a URL for further processing after parsing. + * It is initially the empty string. + */ + public String scheme() { + return this.scheme; + } + + /** + * The protocol getter steps are to return this’s URL’s scheme, followed by U+003A (:). + */ + @SuppressWarnings("unused") + public String protocol() { + return scheme() + ":"; + } + + /** + * A URL’s username is an ASCII string identifying a username. + * It is initially the empty string. + */ + public String username() { + return (this.username != null ? this.username.toString() : ""); + } + + void appendToUsername(int codePoint) { + if (this.username == null) { + this.username = new StringBuilder(2); + } + this.username.appendCodePoint(codePoint); + } + + public void appendToUsername(String s) { + if (this.username == null) { + this.username = new StringBuilder(s); + } + else { + this.username.append(s); + } + } + + /** + * A URL’s password is an ASCII string identifying a password. It is initially the empty string. + */ + public String password() { + return (this.password != null ? this.password.toString() : ""); + } + + void appendToPassword(int codePoint) { + if (this.password == null) { + this.password = new StringBuilder(2); + } + this.password.appendCodePoint(codePoint); + } + + void appendToPassword(String s) { + if (this.password == null) { + this.password = new StringBuilder(s); + } + else { + this.password.append(s); + } + } + + /** + * Convenience method to return the full user info. + */ + @Nullable + public String userInfo() { + if (!includesCredentials()) { + return null; + } + StringBuilder userInfo = new StringBuilder(username()); + if (!password().isEmpty()) { + userInfo.append(':'); + userInfo.append(password()); + } + return userInfo.toString(); + } + + /** + * A URL’s host is {@code null} or a {@linkplain Host host}. + * It is initially {@code null}. + */ + @Nullable + public Host host() { + return this.host; + } + + /** + *The host getter steps are: + *
              + *
            1. Let url be this URL. + *
            2. If url’s host is null, then return the empty string. + *
            3. If url’s port is null, return url’s host, serialized. + *
            4. Return url’s host, serialized, followed by U+003A (:) and url’s port, serialized. + *
            + */ + @SuppressWarnings("unused") + public String hostString() { + if (host() == null) { + return ""; + } + StringBuilder builder = new StringBuilder(hostname()); + Port port = port(); + if (port != null) { + builder.append(':'); + builder.append(port); + } + return builder.toString(); + } + + public String hostname() { + Host host = host(); + return (host != null ? host.toString() : ""); + } + + /** + * A URL’s port is either null, a string representing a 16-bit unsigned + * integer, or a string containing a uri template. + *

            It is initially {@code null}. + */ + @Nullable + public Port port() { + return this.port; + } + + public String portString() { + return (port() != null ? port().toString() : ""); + } + + /** + * A URL’s path is a URL {@linkplain Path path}, usually identifying a location. + *

            It is initially {@code « »}. + */ + public Path path() { + return this.path; + } + + public String pathname() { + return path().name(); + } + + /** + * To shorten a url’s path: + *

              + *
            1. Assert: url does not have an opaque path.
            2. + *
            3. Let path be url’s path.
            4. + *
            5. If url’s scheme is "file", path’s size is 1, and path[0] is a + * normalized Windows drive letter, then return.
            6. + *
            7. Remove path’s last item, if any.
            8. + *
            + */ + public void shortenPath() { + this.path.shorten(this.scheme); + } + + /** + * A URL’s query is either {@code null} or an ASCII string. + *

            It is initially {@code null}. + */ + @Nullable + public String query() { + return (this.query != null ? this.query.toString() : null); + } + + /** + * The search getter steps are: + *

              + *
            1. If this URL’s query is either null or the empty string, then return the empty string. + *
            2. Return U+003F (?), followed by this URL’s query. + *
            + */ + public String search() { + String query = query(); + if (query == null) { + return ""; + } + else { + return "?" + query; + } + } + + /** + * A URL’s fragment is either {@code null} or an ASCII string + * that can be used for further processing on the resource the URL’s + * other components identify. + *

            It is initially {@code null}. + */ + @Nullable + public String fragment() { + return (this.fragment != null ? this.fragment.toString() : null); + } + + /** + * The hash getter steps are: + *

              + *
            1. If this URL’s fragment is either null or the empty string, then return the empty string. + *
            2. Return U+0023 (#), followed by this’s URL’s fragment. + *
            + */ + @SuppressWarnings("unused") + public String hash() { + String fragment = fragment(); + return (fragment != null && !fragment.isEmpty() ? "#" + fragment : ""); + } + + public String href() { + // Let output be url’s scheme and U+003A (:) concatenated. + StringBuilder output = new StringBuilder(scheme()); + output.append(':'); + Host host = host(); + // If url’s host is non-null: + if (host != null) { + // Append "//" to output. + output.append("//"); + // If url includes credentials, then: + if (includesCredentials()) { + // Append url’s username to output. + output.append(username()); + String password = password(); + // If url’s password is not the empty string, then append U+003A (:), + // followed by url’s password, to output. + if (!password.isEmpty()) { + output.append(':'); + output.append(password); + } + // Append U+0040 (@) to output. + output.append('@'); + } + // Append url’s host, serialized, to output. + output.append(hostname()); + Port port = port(); + // If url’s port is non-null, append U+003A (:) followed by url’s port, serialized, to output. + if (port != null) { + output.append(':'); + output.append(port()); + } + } + // If url’s host is null, url does not have an opaque path, url’s path’s size is greater than 1, + // and url’s path[0] is the empty string, then append U+002F (/) followed by U+002E (.) to output. + else if (!hasOpaquePath() && + path() instanceof PathSegments pathSegments && + pathSegments.size() > 1 && + pathSegments.get(0).isEmpty()) { + output.append("/."); + } + // Append the result of URL path serializing url to output. + output.append(pathname()); + // If url’s query is non-null, append U+003F (?), followed by url’s query, to output. + String query = query(); + if (query != null) { + output.append('?'); + output.append(query); + } + // If exclude fragment is false and url’s fragment is non-null, then append U+0023 (#), + // followed by url’s fragment, to output. + String fragment = fragment(); + if (fragment != null) { + output.append('#'); + output.append(fragment); + } + // Return output. + return output.toString(); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null || obj.getClass() != this.getClass()) { + return false; + } + UrlRecord that = (UrlRecord) obj; + return Objects.equals(this.scheme(), that.scheme()) && + Objects.equals(this.username(), that.username()) && + Objects.equals(this.password(), that.password()) && + Objects.equals(this.host(), that.host()) && + Objects.equals(this.port(), that.port()) && + Objects.equals(this.path(), that.path()) && + Objects.equals(this.query(), that.query()) && + Objects.equals(this.fragment(), that.fragment()); + } + + @Override + public int hashCode() { + return Objects.hash( + this.scheme, this.username, this.password, this.host, this.port, + this.path, this.query, this.fragment); + } + + @Override + public String toString() { + return "UrlRecord[" + + "scheme=" + this.scheme + ", " + + "username=" + this.username + ", " + + "password=" + this.password + ", " + + "host=" + this.host + ", " + + "port=" + this.port + ", " + + "path=" + this.path + ", " + + "query=" + this.query + ", " + + "fragment=" + this.fragment + ']'; + } + + } + + + /** + * A host is a domain, an IP address, an opaque host, or an empty host. + * Typically, a host serves as a network address, but it is sometimes used as + * opaque identifier in URLs where a network address is not necessary. + */ + sealed interface Host permits Domain, EmptyHost, IpAddressHost, OpaqueHost { + + /** + * The host parser takes a scalar value string input with an optional + * boolean isOpaque (default false), and then runs these steps. + * They return failure or a host. + */ + static Host parse(String input, boolean isOpaque, WhatWgUrlParser p) { + // If input starts with U+005B ([), then: + if (!input.isEmpty() && input.codePointAt(0) == '[') { + int last = input.length() - 1; + // If input does not end with U+005D (]), IPv6-unclosed validation error, return failure. + if (input.codePointAt(last) != ']') { + throw new InvalidUrlException("IPv6 address is missing the closing \"]\")."); + } + // Return the result of IPv6 parsing input + // with its leading U+005B ([) and trailing U+005D (]) removed. + String ipv6Host = input.substring(1, last); + return new IpAddressHost(Ipv6Address.parse(ipv6Host)); + } + // If isOpaque is true, then return the result of opaque-host parsing input. + if (isOpaque) { + return OpaqueHost.parse(input, p); + } + // Assert: input is not the empty string. + Assert.state(!input.isEmpty(), "Input should not be empty"); + + // Let domain be the result of running UTF-8 decode without BOM on the percent-decoding of input. + String domain = percentDecode(input); + // Let asciiDomain be the result of running domain to ASCII with domain and false. + String asciiDomain = domainToAscii(domain, false); + + for (int i=0; i < asciiDomain.length(); i++) { + int ch = asciiDomain.codePointAt(i); + // If asciiDomain contains a forbidden domain code point, + // domain-invalid-code-point validation error, return failure. + if (isForbiddenDomain(ch)) { + throw new InvalidUrlException("Invalid character \"" + ch + "\" in domain \"" + input + "\""); + } + } + // If asciiDomain ends in a number, then return the result of IPv4 parsing asciiDomain. + if (endsInNumber(asciiDomain)) { + Ipv4Address address = Ipv4Address.parse(asciiDomain, p); + return new IpAddressHost(address); + } + // Return asciiDomain. + else { + return new Domain(asciiDomain); + } + } + + private static boolean endsInNumber(String input) { + // Let parts be the result of strictly splitting input on U+002E (.). + LinkedList parts = strictSplit(input, '.'); + if (parts.isEmpty()) { + return false; + } + // If the last item in parts is the empty string, then: + if (parts.getLast().isEmpty()) { + // If parts’s size is 1, then return false. + if (parts.size() == 1) { + return false; + } + // Remove the last item from parts. + parts.removeLast(); + } + // Let last be the last item in parts. + String last = parts.getLast(); + // If last is non-empty and contains only ASCII digits, then return true. + if (!last.isEmpty() && containsOnlyAsciiDigits(last)) { + return true; + } + // If parsing last as an IPv4 number does not return failure, then return true. + ParseIpv4NumberResult result = Ipv4Address.parseIpv4Number(last); + return result != ParseIpv4NumberFailure.INSTANCE; + } + } + + + /** + * A domain is a non-empty ASCII string that identifies a realm within a network. [RFC1034]. + */ + static final class Domain implements Host { + + private final String domain; + + Domain(String domain) { + this.domain = domain; + } + + public String domain() { + return this.domain; + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + else if (o instanceof Domain other) { + return this.domain.equals(other.domain); + } + else { + return false; + } + } + + @Override + public int hashCode() { + return this.domain.hashCode(); + } + + @Override + public String toString() { + return this.domain; + } + + } + + + static final class IpAddressHost implements Host { + + private final IpAddress address; + + private final String addressString; + + IpAddressHost(IpAddress address) { + this.address = address; + if (address instanceof Ipv6Address) { + this.addressString = "[" + address + "]"; + } + else { + this.addressString = address.toString(); + } + } + + @SuppressWarnings("unused") + public IpAddress address() { + return this.address; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + else if (obj instanceof IpAddressHost other) { + return this.address.equals(other.address); + } + else { + return false; + } + } + + @Override + public int hashCode() { + return this.address.hashCode(); + } + + @Override + public String toString() { + return this.addressString; + } + } + + + static final class OpaqueHost implements Host { + + private final String host; + + private OpaqueHost(String host) { + this.host = host; + } + + /** + * The opaque-host parser takes a scalar value string input, + * and then runs these steps. They return failure or an opaque host. + */ + public static OpaqueHost parse(String input, WhatWgUrlParser p) { + for (int i = 0; i < input.length(); i++) { + int ch = input.codePointAt(i); + // If input contains a forbidden host code point, h + // ost-invalid-code-point validation error, return failure. + if (isForbiddenHost(ch)) { + throw new InvalidUrlException("An opaque host contains a forbidden host code point."); + } + // If input contains a code point that is not a URL code point and not U+0025 (%), + // invalid-URL-unit validation error. + if (p.validate() && !isUrlCodePoint(ch) && ch != '%') { + p.validationError("Code point \"" + ch + "\" is not a URL unit."); + } + // If input contains a U+0025 (%) and the two code points following it + // are not ASCII hex digits, invalid-URL-unit validation error. + if (p.validate() && ch == '%' && + (input.length() - i < 2 || !isAsciiDigit(input.codePointAt(i + 1)) || + !isAsciiDigit(input.codePointAt(i + 2)))) { + p.validationError("Code point \"" + ch + "\" is not a URL unit."); + } + } + // Return the result of running UTF-8 percent-encode on input + // using the C0 control percent-encode set. + String encoded = p.percentEncode(input, WhatWgUrlParser::c0ControlPercentEncodeSet); + return new OpaqueHost(encoded); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + else if (obj instanceof OpaqueHost other) { + return this.host.equals(other.host); + } + else { + return false; + } + } + + @Override + public int hashCode() { + return this.host.hashCode(); + } + + @Override + public String toString() { + return this.host; + } + + } + + + static final class EmptyHost implements Host { + + static final EmptyHost INSTANCE = new EmptyHost(); + + private EmptyHost() { + } + + @Override + public boolean equals(Object obj) { + return obj == this || obj != null && obj.getClass() == this.getClass(); + } + + @Override + public int hashCode() { + return 1; + } + + @Override + public String toString() { + return ""; + } + + } + + + sealed interface IpAddress permits Ipv4Address, Ipv6Address { + } + + + static final class Ipv4Address implements IpAddress { + + private final int address; + + private final String string; + + Ipv4Address(int address) { + this.address = address; + this.string = serialize(address); + } + + /** + * The IPv4 serializer takes an IPv4 address {@code address} and then runs these steps. + * They return an ASCII string. + */ + private static String serialize(int address) { + //Let output be the empty string. + StringBuilder output = new StringBuilder(); + //Let n be the value of address. + int n = address; + //For each i in the range 1 to 4, inclusive: + for (int i = 1; i <= 4; i++) { + // Prepend n % 256, serialized, to output. + output.insert(0, Integer.toUnsignedString(Integer.remainderUnsigned(n, 256))); + //If i is not 4, then prepend U+002E (.) to output. + if (i != 4) { + output.insert(0, '.'); + } + //Set n to floor(n / 256). + n = Math.floorDiv(n, 256); + } + //Return output. + return output.toString(); + } + + public static Ipv4Address parse(String input, WhatWgUrlParser p) { + // Let parts be the result of strictly splitting input on U+002E (.). + List parts = strictSplit(input, '.'); + int partsSize = parts.size(); + // If the last item in parts is the empty string, then: + if (parts.get(partsSize - 1).isEmpty()) { + // IPv4-empty-part validation error. + p.validationError("IPv4 address ends with \".\""); + // If parts’s size is greater than 1, then remove the last item from parts. + if (partsSize > 1) { + parts.remove(partsSize - 1); + partsSize--; + } + } + // If parts’s size is greater than 4, IPv4-too-many-parts validation error, return failure. + if (partsSize > 4) { + throw new InvalidUrlException("IPv4 address does not consist of exactly 4 parts."); + } + // Let numbers be an empty list. + List numbers = new ArrayList<>(partsSize); + // For each part of parts: + for (int i = 0; i < partsSize; i++) { + String part = parts.get(i); + // Let result be the result of parsing part. + ParseIpv4NumberResult result = parseIpv4Number(part); + // If result is failure, IPv4-non-numeric-part validation error, return failure. + if (result == ParseIpv4NumberFailure.INSTANCE) { + p.failure("An IPv4 address part is not numeric."); + } + else { + ParseIpv4NumberSuccess success = (ParseIpv4NumberSuccess) result; + if (p.validate() && success.validationError()) { + p.validationError( + "The IPv4 address contains numbers expressed using hexadecimal or octal digits."); + } + // Append result to numbers. + numbers.add(success.number()); + } + } + for (Iterator iterator = numbers.iterator(); iterator.hasNext(); ) { + Integer number = iterator.next(); + // If any item in numbers is greater than 255, IPv4-out-of-range-part validation error. + if (p.validate() && number > 255) { + p.validationError("An IPv4 address part exceeds 255."); + } + if (iterator.hasNext()) { + // If any but the last item in numbers is greater than 255, then return failure. + if (number > 255) { + throw new InvalidUrlException("An IPv4 address part exceeds 255."); + } + } + else { + // If the last item in numbers is greater than or equal to 256^(5 − numbers’s size), + // then return failure. + double limit = Math.pow(256, (5 - numbers.size())); + if (number >= limit) { + throw new InvalidUrlException( + "IPv4 address part " + number + " exceeds " + limit + ".'"); + } + } + } + // Let ipv4 be the last item in numbers. + int ipv4 = numbers.get(numbers.size() - 1); + // Remove the last item from numbers. + numbers.remove(numbers.size() - 1); + // Let counter be 0. + int counter = 0; + // For each n of numbers: + for (Integer n : numbers) { + // Increment ipv4 by n × 256^(3 − counter). + int increment = n * (int) Math.pow(256, 3 - counter); + ipv4 += increment; + // Increment counter by 1. + counter++; + } + // Return ipv4. + return new Ipv4Address(ipv4); + } + + /** + * The IPv4 number parser takes an ASCII string input and then runs these steps. + * They return failure or a tuple of a number and a boolean. + */ + private static ParseIpv4NumberResult parseIpv4Number(String input) { + // If input is the empty string, then return failure. + if (input.isEmpty()) { + return ParseIpv4NumberFailure.INSTANCE; + } + // Let validationError be false. + boolean validationError = false; + // Let R be 10. + int r = 10; + int len = input.length(); + // If input contains at least two code points and + // the first two code points are either "0X" or "0x", then: + if (len >= 2) { + int ch0 = input.codePointAt(0); + int ch1 = input.codePointAt(1); + if (ch0 == '0' && (ch1 == 'X' || ch1 == 'x')) { + // Set validationError to true. + validationError = true; + // Remove the first two code points from input. + input = input.substring(2); + // Set R to 16. + r = 16; + } + // Otherwise, if input contains at least two code points and + // the first code point is U+0030 (0), then: + else if (ch0 == '0') { + // Set validationError to true. + validationError = true; + // Remove the first code point from input. + input = input.substring(1); + // Set R to 8. + r = 8; + } + } + // If input is the empty string, then return (0, true). + if (input.isEmpty()) { + return new ParseIpv4NumberSuccess(0, true); + } + // If input contains a code point that is not a radix-R digit, then return failure. + for (int i = 0; i < input.length(); i++) { + int c = input.codePointAt(i); + int digit = Character.digit(c, r); + if (digit == -1) { + return ParseIpv4NumberFailure.INSTANCE; + } + } + try { + // Let output be the mathematical integer value that is represented by + // input in radix-R notation, using ASCII hex digits for digits with values 0 through 15. + int output = Integer.parseInt(input, r); + // Return (output, validationError). + return new ParseIpv4NumberSuccess(output, validationError); + } + catch (NumberFormatException ex) { + return ParseIpv4NumberFailure.INSTANCE; + } + } + + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + else if (o instanceof Ipv4Address other) { + return this.address == other.address; + } + else { + return false; + } + } + + @Override + public int hashCode() { + return this.address; + } + + @Override + public String toString() { + return this.string; + } + } + + + static final class Ipv6Address implements IpAddress { + + private final int[] pieces; + + private final String string; + + private Ipv6Address(int[] pieces) { + Assert.state(pieces.length == 8, "Invalid amount of IPv6 pieces"); + this.pieces = pieces; + this.string = serialize(pieces); + } + + /** + * The IPv6 parser takes a scalar value string input and then runs these steps. + * They return failure or an IPv6 address. + */ + public static Ipv6Address parse(String input) { + // Let address be a new IPv6 address whose IPv6 pieces are all 0. + int[] address = new int[8]; + // Let pieceIndex be 0. + int pieceIndex = 0; + // Let compress be null. + Integer compress = null; + // Let pointer be a pointer for input. + int pointer = 0; + int inputLength = input.length(); + int c = (inputLength > 0) ? input.codePointAt(0) : EOF; + // If c is U+003A (:), then: + if (c == ':') { + // If remaining does not start with U+003A (:), + // IPv6-invalid-compression validation error, return failure. + if (inputLength > 1 && input.codePointAt(1) != ':') { + throw new InvalidUrlException("IPv6 address begins with improper compression."); + } + // Increase pointer by 2. + pointer += 2; + // Increase pieceIndex by 1 and then set compress to pieceIndex. + pieceIndex++; + compress = pieceIndex; + } + c = (pointer < inputLength) ? input.codePointAt(pointer) : EOF; + // While c is not the EOF code point: + while (c != EOF) { + // If pieceIndex is 8, IPv6-too-many-pieces validation error, return failure. + if (pieceIndex == 8) { + throw new InvalidUrlException("IPv6 address contains more than 8 pieces."); + } + // If c is U+003A (:), then: + if (c == ':') { + // If compress is non-null, IPv6-multiple-compression validation error, return failure. + if (compress != null) { + throw new InvalidUrlException("IPv6 address is compressed in more than one spot."); + } + // Increase pointer and pieceIndex by 1, set compress to pieceIndex, and then continue. + pointer++; + pieceIndex++; + compress = pieceIndex; + c = (pointer < inputLength) ? input.codePointAt(pointer) : EOF; + continue; + } + // Let value and length be 0. + int value = 0; + int length = 0; + // While length is less than 4 and c is an ASCII hex digit, + // set value to value × 0x10 + c interpreted as hexadecimal number, + // and increase pointer and length by 1. + while (length < 4 && isAsciiHexDigit(c)) { + int cHex = Character.digit(c, 16); + value = (value * 0x10) + cHex; + pointer++; + length++; + c = (pointer < inputLength) ? input.codePointAt(pointer) : EOF; + } + // If c is U+002E (.), then: + if (c == '.') { + // If length is 0, IPv4-in-IPv6-invalid-code-point validation error, return failure. + if (length == 0) { + throw new InvalidUrlException( + "IPv6 address with IPv4 address syntax: IPv4 part is empty."); + } + // Decrease pointer by length. + pointer -= length; + // If pieceIndex is greater than 6, + // IPv4-in-IPv6-too-many-pieces validation error, return failure. + if (pieceIndex > 6) { + throw new InvalidUrlException( + "IPv6 address with IPv4 address syntax: IPv6 address has more than 6 pieces."); + } + // Let numbersSeen be 0. + int numbersSeen = 0; + c = (pointer < inputLength) ? input.codePointAt(pointer) : EOF; + // While c is not the EOF code point: + while (c != EOF) { + // Let ipv4Piece be null. + Integer ipv4Piece = null; + // If numbersSeen is greater than 0, then: + if (numbersSeen > 0) { + // If c is a U+002E (.) and numbersSeen is less than 4, then increase pointer by 1. + if (c =='.' && numbersSeen < 4) { + pointer++; + c = (pointer < inputLength) ? input.codePointAt(pointer) : EOF; + } + // Otherwise, IPv4-in-IPv6-invalid-code-point validation error, return failure. + else { + throw new InvalidUrlException( + "IPv6 address with IPv4 address syntax: " + + "IPv4 part is empty or contains a non-ASCII digit."); + } + } + // If c is not an ASCII digit, + // IPv4-in-IPv6-invalid-code-point validation error, return failure. + if (!isAsciiDigit(c)) { + throw new InvalidUrlException( + "IPv6 address with IPv4 address syntax: IPv4 part contains a non-ASCII digit."); + } + // While c is an ASCII digit: + while (isAsciiDigit(c)) { + // Let number be c interpreted as decimal number. + int number = Character.digit(c, 10); + // If ipv4Piece is null, then set ipv4Piece to number. + if (ipv4Piece == null) { + ipv4Piece = number; + } + // Otherwise, if ipv4Piece is 0, + // IPv4-in-IPv6-invalid-code-point validation error, return failure. + else if (ipv4Piece == 0) { + throw new InvalidUrlException( + "IPv6 address with IPv4 address syntax: IPv4 part contains a non-ASCII digit."); + } + // Otherwise, set ipv4Piece to ipv4Piece × 10 + number. + else { + ipv4Piece = ipv4Piece * 10 + number; + } + // If ipv4Piece is greater than 255, + // IPv4-in-IPv6-out-of-range-part validation error, return failure. + if (ipv4Piece > 255) { + throw new InvalidUrlException( + "IPv6 address with IPv4 address syntax: IPv4 part exceeds 255."); + } + // Increase pointer by 1. + pointer++; + c = (pointer < inputLength) ? input.codePointAt(pointer) : EOF; + } + // Set address[pieceIndex] to address[pieceIndex] × 0x100 + ipv4Piece. + address[pieceIndex] = address[pieceIndex] * 0x100 + (ipv4Piece != null ? ipv4Piece : 0); + // Increase numbersSeen by 1. + numbersSeen++; + // If numbersSeen is 2 or 4, then increase pieceIndex by 1. + if (numbersSeen == 2 || numbersSeen == 4) { + pieceIndex++; + } + c = (pointer < inputLength) ? input.codePointAt(pointer) : EOF; + } + // If numbersSeen is not 4, + // IPv4-in-IPv6-too-few-parts validation error, return failure. + if (numbersSeen != 4) { + throw new InvalidUrlException( + "IPv6 address with IPv4 address syntax: IPv4 address contains too few parts."); + } + // Break. + break; + } + // Otherwise, if c is U+003A (:): + else if (c == ':') { + // Increase pointer by 1. + pointer++; + c = (pointer < inputLength) ? input.codePointAt(pointer) : EOF; + // If c is the EOF code point, IPv6-invalid-code-point validation error, return failure. + if (c == EOF) { + throw new InvalidUrlException("IPv6 address unexpectedly ends."); + } + } + // Otherwise, if c is not the EOF code point, + // IPv6-invalid-code-point validation error, return failure. + else if (c != EOF) { + throw new InvalidUrlException( + "IPv6 address contains \"" + Character.toString(c) + "\", which is " + + "neither an ASCII hex digit nor a ':'."); + } + // Set address[pieceIndex] to value. + address[pieceIndex] = value; + // Increase pieceIndex by 1. + pieceIndex++; + } + // If compress is non-null, then: + if (compress != null) { + // Let swaps be pieceIndex − compress. + int swaps = pieceIndex - compress; + // Set pieceIndex to 7. + pieceIndex = 7; + // While pieceIndex is not 0 and swaps is greater than 0, + // swap address[pieceIndex] with address[compress + swaps − 1], and + // then decrease both pieceIndex and swaps by 1. + while (pieceIndex != 0 && swaps > 0) { + int tmp = address[pieceIndex]; + address[pieceIndex] = address[compress + swaps - 1]; + address[compress + swaps - 1] = tmp; + pieceIndex--; + swaps--; + } + } + // Otherwise, if compress is null and pieceIndex is not 8, + // IPv6-too-few-pieces validation error, return failure. + else if (pieceIndex != 8) { + throw new InvalidUrlException("An uncompressed IPv6 address contains fewer than 8 pieces."); + } + // Return address. + return new Ipv6Address(address); + } + + + /** + * The IPv6 serializer takes an IPv6 address {@code address} and + * then runs these steps. They return an ASCII string. + */ + private static String serialize(int[] address) { + // Let output be the empty string. + StringBuilder output = new StringBuilder(); + // Let compress be an index to the first IPv6 piece in + // the first longest sequences of address’s IPv6 pieces that are 0. + int compress = longestSequenceOf0Pieces(address); + // Let ignore0 be false. + boolean ignore0 = false; + // For each pieceIndex in the range 0 to 7, inclusive: + for (int pieceIndex = 0; pieceIndex <= 7; pieceIndex++) { + // If ignore0 is true and address[pieceIndex] is 0, then continue. + if (ignore0 && address[pieceIndex] == 0) { + continue; + } + // Otherwise, if ignore0 is true, set ignore0 to false. + else if (ignore0) { + ignore0 = false; + } + // If compress is pieceIndex, then: + if (compress == pieceIndex) { + // Let separator be "::" if pieceIndex is 0, and U+003A (:) otherwise. + String separator = (pieceIndex == 0) ? "::" : ":"; + // Append separator to output. + output.append(separator); + // Set ignore0 to true and continue. + ignore0 = true; + continue; + } + // Append address[pieceIndex], represented as + // the shortest possible lowercase hexadecimal number, to output. + output.append(Integer.toHexString(address[pieceIndex])); + // If pieceIndex is not 7, then append U+003A (:) to output. + if (pieceIndex != 7) { + output.append(':'); + } + } + // Return output. + return output.toString(); + } + + private static int longestSequenceOf0Pieces(int[] pieces) { + int longestStart = -1; + int longestLength = -1; + int start = -1; + for (int i = 0; i < pieces.length + 1; i++) { + if (i < pieces.length && pieces[i] == 0) { + if (start < 0) { + start = i; + } + } + else if (start >= 0) { + int length = i - start; + if (length > longestLength) { + longestStart = start; + longestLength = length; + } + start = -1; + } + } + // If there is no sequence of address’s IPv6 pieces + // that are 0 that is longer than 1, then set compress to null. + if (longestLength > 1) { + return longestStart; + } + else { + return -1; + } + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + else if (obj instanceof Ipv6Address other) { + return Arrays.equals(this.pieces, other.pieces); + } + else { + return false; + } + } + + @Override + public int hashCode() { + return Arrays.hashCode(this.pieces); + } + + @Override + public String toString() { + return this.string; + } + } + + + sealed interface Port permits StringPort, IntPort { + } + + + static final class StringPort implements Port { + + private final String port; + + public StringPort(String port) { + this.port = port; + } + + public String value() { + return this.port; + } + + @Override + public String toString() { + return this.port; + } + } + + + static final class IntPort implements Port { + + private final int port; + + public IntPort(int port) { + this.port = port; + } + + public int value() { + return this.port; + } + + @Override + public String toString() { + return Integer.toString(this.port); + } + + } + + + sealed interface Path permits PathSegment, PathSegments { + + void append(int codePoint); + + void append(String s); + + boolean isEmpty(); + + void shorten(String scheme); + + boolean isOpaque(); + + Path clone(); + + String name(); + } + + + static final class PathSegment implements Path { + + @Nullable + private StringBuilder builder = null; + + @Nullable + String segment; + + PathSegment(String segment) { + this.segment = segment; + } + + PathSegment(int codePoint) { + append(codePoint); + } + + public String segment() { + String result = this.segment; + if (result == null) { + Assert.state(this.builder != null, "String nor StringBuilder available"); + result = this.builder.toString(); + this.segment = result; + } + return result; + } + + @Override + public void append(int codePoint) { + this.segment = null; + if (this.builder == null) { + this.builder = new StringBuilder(2); + } + this.builder.appendCodePoint(codePoint); + } + + @Override + public void append(String s) { + this.segment = null; + if (this.builder == null) { + this.builder = new StringBuilder(s); + } + else { + this.builder.append(s); + } + } + + @Override + public String name() { + String name = segment(); + if (name.startsWith("/")) { + name = name.substring(1); + } + return name; + } + + @Override + public boolean isEmpty() { + if (this.segment != null) { + return this.segment.isEmpty(); + } + else { + Assert.state(this.builder != null, "String nor StringBuilder available"); + return this.builder.isEmpty(); + } + } + + @Override + public void shorten(String scheme) { + throw new IllegalStateException("Opaque path not expected"); + } + + @Override + public boolean isOpaque() { + return true; + } + + @SuppressWarnings("MethodDoesntCallSuperMethod") + @Override + public Path clone() { + return new PathSegment(segment()); + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + else if (o instanceof PathSegment other) { + return segment().equals(other.segment()); + } + else { + return false; + } + } + + @Override + public int hashCode() { + return segment().hashCode(); + } + + @Override + public String toString() { + return segment(); + } + } + + + static final class PathSegments implements Path { + + private final List segments; + + public PathSegments() { + this.segments = new ArrayList<>(); + } + + public PathSegments(List segments) { + this.segments = new ArrayList<>(segments); + } + + + @Override + public void append(int codePoint) { + this.segments.add(new PathSegment(codePoint)); + } + + @Override + public void append(String segment) { + this.segments.add(new PathSegment(segment)); + } + + public int size() { + return this.segments.size(); + } + + public String get(int i) { + return this.segments.get(i).segment(); + } + + @Override + public boolean isEmpty() { + return this.segments.isEmpty(); + } + + @Override + public void shorten(String scheme) { + int size = size(); + if ("file".equals(scheme) && + size == 1 && + isWindowsDriveLetter(get(0), true)) { + return; + } + if (!isEmpty()) { + this.segments.remove(size - 1); + } + } + + @Override + public boolean isOpaque() { + return false; + } + + @SuppressWarnings("MethodDoesntCallSuperMethod") + @Override + public Path clone() { + return new PathSegments(this.segments); + } + + @Override + public String name() { + StringBuilder output = new StringBuilder(); + for (PathSegment segment : this.segments) { + output.append('/'); + output.append(segment.name()); + } + return output.toString(); + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + else if (o instanceof PathSegments other) { + return this.segments.equals(other.segments); + } + else { + return false; + } + } + + @Override + public int hashCode() { + return this.segments.hashCode(); + } + + @Override + public String toString() { + StringBuilder output = new StringBuilder(); + for (PathSegment segment : this.segments) { + output.append(segment); + } + return output.toString(); + } + + } + + + private sealed interface ParseIpv4NumberResult permits ParseIpv4NumberFailure, ParseIpv4NumberSuccess { + } + + + private record ParseIpv4NumberSuccess(int number, boolean validationError) implements ParseIpv4NumberResult { + } + + + private static final class ParseIpv4NumberFailure implements ParseIpv4NumberResult { + + public static final ParseIpv4NumberFailure INSTANCE = new ParseIpv4NumberFailure(); + + private ParseIpv4NumberFailure() { + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/util/pattern/CaptureVariablePathElement.java b/spring-web/src/main/java/org/springframework/web/util/pattern/CaptureVariablePathElement.java index b3002295a9f2..8acf0c7907fa 100644 --- a/spring-web/src/main/java/org/springframework/web/util/pattern/CaptureVariablePathElement.java +++ b/spring-web/src/main/java/org/springframework/web/util/pattern/CaptureVariablePathElement.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +73,7 @@ public boolean matches(int pathIndex, PathPattern.MatchingContext matchingContex return false; } String candidateCapture = matchingContext.pathElementValue(pathIndex); - if (candidateCapture.length() == 0) { + if (candidateCapture.isEmpty()) { return false; } diff --git a/spring-web/src/main/java/org/springframework/web/util/pattern/InternalPathPatternParser.java b/spring-web/src/main/java/org/springframework/web/util/pattern/InternalPathPatternParser.java index e47cd12f459b..53e511ae0f07 100644 --- a/spring-web/src/main/java/org/springframework/web/util/pattern/InternalPathPatternParser.java +++ b/spring-web/src/main/java/org/springframework/web/util/pattern/InternalPathPatternParser.java @@ -380,7 +380,7 @@ else if (this.singleCharWildcardCount != 0) { /** * For a path element representing a captured variable, locate the constraint pattern. * Assumes there is a constraint pattern. - * @param data a complete path expression, e.g. /aaa/bbb/{ccc:...} + * @param data a complete path expression, for example, /aaa/bbb/{ccc:...} * @param offset the start of the capture pattern of interest * @return the index of the character after the ':' within * the pattern expression relative to the start of the whole expression diff --git a/spring-web/src/main/java/org/springframework/web/util/pattern/PathPatternParser.java b/spring-web/src/main/java/org/springframework/web/util/pattern/PathPatternParser.java index 7e9d2fa3abee..655d602f102b 100644 --- a/spring-web/src/main/java/org/springframework/web/util/pattern/PathPatternParser.java +++ b/spring-web/src/main/java/org/springframework/web/util/pattern/PathPatternParser.java @@ -121,7 +121,7 @@ public String initFullPathPattern(String pattern) { * stage. Produces a PathPattern object that can be used for fast matching * against paths. Each invocation of this method delegates to a new instance of * the {@link InternalPathPatternParser} because that class is not thread-safe. - * @param pathPattern the input path pattern, e.g. /project/{name} + * @param pathPattern the input path pattern, for example, /project/{name} * @return a PathPattern for quickly matching paths against request paths * @throws PatternParseException in case of parse errors */ diff --git a/spring-web/src/main/java/org/springframework/web/util/pattern/WildcardPathElement.java b/spring-web/src/main/java/org/springframework/web/util/pattern/WildcardPathElement.java index 0fa2be98b9af..61c584f9c494 100644 --- a/spring-web/src/main/java/org/springframework/web/util/pattern/WildcardPathElement.java +++ b/spring-web/src/main/java/org/springframework/web/util/pattern/WildcardPathElement.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -66,7 +66,7 @@ public boolean matches(int pathIndex, MatchingContext matchingContext) { } else { return (matchingContext.isMatchOptionalTrailingSeparator() && // if optional slash is on... - segmentData != null && segmentData.length() > 0 && // and there is at least one character to match the *... + segmentData != null && !segmentData.isEmpty() && // and there is at least one character to match the *... (pathIndex + 1) == matchingContext.pathLength && // and the next path element is the end of the candidate... matchingContext.isSeparator(pathIndex)); // and the final element is a separator } @@ -74,7 +74,7 @@ public boolean matches(int pathIndex, MatchingContext matchingContext) { } else { // Within a path (e.g. /aa/*/bb) there must be at least one character to match the wildcard - if (segmentData == null || segmentData.length() == 0) { + if (segmentData == null || segmentData.isEmpty()) { return false; } return (this.next != null && this.next.matches(pathIndex, matchingContext)); diff --git a/spring-web/src/main/kotlin/org/springframework/web/client/RestClientExtensions.kt b/spring-web/src/main/kotlin/org/springframework/web/client/RestClientExtensions.kt index 9ed98fcad6e4..12092af8dfea 100644 --- a/spring-web/src/main/kotlin/org/springframework/web/client/RestClientExtensions.kt +++ b/spring-web/src/main/kotlin/org/springframework/web/client/RestClientExtensions.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,6 +42,14 @@ inline fun RestClient.RequestBodySpec.bodyWithType(body: T): R inline fun RestClient.ResponseSpec.body(): T? = body(object : ParameterizedTypeReference() {}) +/** + * Extension for [RestClient.ResponseSpec.body] providing a `requiredBody()` variant with a non-nullable + * return value. + * @throws NoSuchElementException if there is no response body + * @since 6.2 + */ +inline fun RestClient.ResponseSpec.requiredBody(): T = + body(object : ParameterizedTypeReference() {}) ?: throw NoSuchElementException("Response body is required") /** * Extension for [RestClient.ResponseSpec.toEntity] providing a `toEntity()` variant @@ -52,4 +60,4 @@ inline fun RestClient.ResponseSpec.body(): T? = * @since 6.1 */ inline fun RestClient.ResponseSpec.toEntity(): ResponseEntity = - toEntity(object : ParameterizedTypeReference() {}) + toEntity(object : ParameterizedTypeReference() {}) \ No newline at end of file diff --git a/spring-web/src/main/kotlin/org/springframework/web/server/CoWebExceptionHandler.kt b/spring-web/src/main/kotlin/org/springframework/web/server/CoWebExceptionHandler.kt new file mode 100644 index 000000000000..12f1987fb7c7 --- /dev/null +++ b/spring-web/src/main/kotlin/org/springframework/web/server/CoWebExceptionHandler.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.server + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.reactor.mono +import reactor.core.publisher.Mono +import kotlin.coroutines.CoroutineContext + +/** + * Kotlin-specific implementation of the [WebExceptionHandler] interface that allows for + * using coroutines, including [kotlin.coroutines.CoroutineContext] propagation. + * + * @author Sangyoon Jeong + * @since 6.2 + */ +abstract class CoWebExceptionHandler : WebExceptionHandler { + + final override fun handle(exchange: ServerWebExchange, ex: Throwable): Mono { + val context = exchange.attributes[CoWebFilter.COROUTINE_CONTEXT_ATTRIBUTE] as CoroutineContext? + return mono(context ?: Dispatchers.Unconfined) { coHandle(exchange, ex) }.then() + } + + protected abstract suspend fun coHandle(exchange: ServerWebExchange, ex: Throwable) +} diff --git a/spring-web/src/main/kotlin/org/springframework/web/server/CoWebFilter.kt b/spring-web/src/main/kotlin/org/springframework/web/server/CoWebFilter.kt index 4cf319dee637..3a3e3760ba03 100644 --- a/spring-web/src/main/kotlin/org/springframework/web/server/CoWebFilter.kt +++ b/spring-web/src/main/kotlin/org/springframework/web/server/CoWebFilter.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-web/src/main/resources/org/springframework/http/mime.types b/spring-web/src/main/resources/org/springframework/http/mime.types index 597425c11846..4ec092b66d86 100644 --- a/spring-web/src/main/resources/org/springframework/http/mime.types +++ b/spring-web/src/main/resources/org/springframework/http/mime.types @@ -11,237 +11,73 @@ # Internet media types should be registered as described in RFC 4288. # The registry is at . # -# This file was retrieved from https://svn.apache.org/viewvc/httpd/httpd/trunk/docs/conf/mime.types?revision=1752884&view=co +# This file was retrieved from https://svn.apache.org/viewvc/httpd/httpd/trunk/docs/conf/mime.types?revision=1918129&view=co # # MIME type (lowercased) Extensions # ============================================ ========== -# application/1d-interleaved-parityfec -# application/3gpdash-qoe-report+xml -# application/3gpp-ims+xml -# application/a2l -# application/activemessage -# application/alto-costmap+json -# application/alto-costmapfilter+json -# application/alto-directory+json -# application/alto-endpointcost+json -# application/alto-endpointcostparams+json -# application/alto-endpointprop+json -# application/alto-endpointpropparams+json -# application/alto-error+json -# application/alto-networkmap+json -# application/alto-networkmapfilter+json -# application/aml application/andrew-inset ez -# application/applefile application/applixware aw -# application/atf -# application/atfx application/atom+xml atom application/atomcat+xml atomcat -# application/atomdeleted+xml -# application/atomicmail application/atomsvc+xml atomsvc -# application/atxml -# application/auth-policy+xml -# application/bacnet-xdd+zip -# application/batch-smtp -# application/beep+xml -# application/calendar+json -# application/calendar+xml -# application/call-completion -# application/cals-1840 -# application/cbor -# application/ccmp+xml application/ccxml+xml ccxml -# application/cdfx+xml application/cdmi-capability cdmia application/cdmi-container cdmic application/cdmi-domain cdmid application/cdmi-object cdmio application/cdmi-queue cdmiq -# application/cdni -# application/cea -# application/cea-2018+xml -# application/cellml+xml -# application/cfw -# application/cms -# application/cnrp+xml -# application/coap-group+json -# application/commonground -# application/conference-info+xml -# application/cpl+xml -# application/csrattrs -# application/csta+xml -# application/cstadata+xml -# application/csvm+json application/cu-seeme cu -# application/cybercash -# application/dash+xml -# application/dashdelta application/davmount+xml davmount -# application/dca-rft -# application/dcd -# application/dec-dx -# application/dialog-info+xml -# application/dicom -# application/dii -# application/dit -# application/dns application/docbook+xml dbk -# application/dskpp+xml application/dssc+der dssc application/dssc+xml xdssc -# application/dvcs application/ecmascript ecma -# application/edi-consent -# application/edi-x12 -# application/edifact -# application/efi -# application/emergencycalldata.comment+xml -# application/emergencycalldata.deviceinfo+xml -# application/emergencycalldata.providerinfo+xml -# application/emergencycalldata.serviceinfo+xml -# application/emergencycalldata.subscriberinfo+xml application/emma+xml emma -# application/emotionml+xml -# application/encaprtp -# application/epp+xml application/epub+zip epub -# application/eshop -# application/example application/exi exi -# application/fastinfoset -# application/fastsoap -# application/fdt+xml -# application/fits -# application/font-sfnt application/font-tdpfr pfr -application/font-woff woff -# application/framework-attributes+xml -# application/geo+json application/gml+xml gml application/gpx+xml gpx application/gxf gxf -# application/gzip -# application/h224 -# application/held+xml -# application/http application/hyperstudio stk -# application/ibe-key-request+xml -# application/ibe-pkg-reply+xml -# application/ibe-pp-data -# application/iges -# application/im-iscomposing+xml -# application/index -# application/index.cmd -# application/index.obj -# application/index.response -# application/index.vnd application/inkml+xml ink inkml -# application/iotp application/ipfix ipfix -# application/ipp -# application/isup -# application/its+xml application/java-archive jar application/java-serialized-object ser application/java-vm class -application/javascript js -# application/jose -# application/jose+json -# application/jrd+json application/json json -# application/json-patch+json -# application/json-seq application/jsonml+json jsonml -# application/jwk+json -# application/jwk-set+json -# application/jwt -# application/kpml-request+xml -# application/kpml-response+xml -# application/ld+json -# application/lgr+xml -# application/link-format -# application/load-control+xml application/lost+xml lostxml -# application/lostsync+xml -# application/lxf application/mac-binhex40 hqx application/mac-compactpro cpt -# application/macwriteii application/mads+xml mads application/marc mrc application/marcxml+xml mrcx application/mathematica ma nb mb application/mathml+xml mathml -# application/mathml-content+xml -# application/mathml-presentation+xml -# application/mbms-associated-procedure-description+xml -# application/mbms-deregister+xml -# application/mbms-envelope+xml -# application/mbms-msk+xml -# application/mbms-msk-response+xml -# application/mbms-protection-description+xml -# application/mbms-reception-report+xml -# application/mbms-register+xml -# application/mbms-register-response+xml -# application/mbms-schedule+xml -# application/mbms-user-service-description+xml application/mbox mbox -# application/media-policy-dataset+xml -# application/media_control+xml application/mediaservercontrol+xml mscml -# application/merge-patch+json application/metalink+xml metalink application/metalink4+xml meta4 application/mets+xml mets -# application/mf4 -# application/mikey application/mods+xml mods -# application/moss-keys -# application/moss-signature -# application/mosskey-data -# application/mosskey-request application/mp21 m21 mp21 application/mp4 mp4s -# application/mpeg4-generic -# application/mpeg4-iod -# application/mpeg4-iod-xmt -# application/mrb-consumer+xml -# application/mrb-publish+xml -# application/msc-ivr+xml -# application/msc-mixer+xml application/msword doc dot application/mxf mxf -# application/nasdata -# application/news-checkgroups -# application/news-groupinfo -# application/news-transmission -# application/nlsml+xml -# application/nss -# application/ocsp-request -# application/ocsp-response application/octet-stream bin dms lrf mar so dist distz pkg bpk dump elc deploy application/oda oda -# application/odx application/oebps-package+xml opf application/ogg ogx application/omdoc+xml omdoc application/onenote onetoc onetoc2 onetmp onepkg application/oxps oxps -# application/p2p-overlay+xml -# application/parityfec application/patch-ops-error+xml xer application/pdf pdf -# application/pdx application/pgp-encrypted pgp -# application/pgp-keys application/pgp-signature asc sig application/pics-rules prf -# application/pidf+xml -# application/pidf-diff+xml application/pkcs10 p10 -# application/pkcs12 application/pkcs7-mime p7m p7c application/pkcs7-signature p7s application/pkcs8 p8 @@ -251,201 +87,83 @@ application/pkix-crl crl application/pkix-pkipath pkipath application/pkixcmp pki application/pls+xml pls -# application/poc-settings+xml application/postscript ai eps ps -# application/ppsp-tracker+json -# application/problem+json -# application/problem+xml -# application/provenance+xml -# application/prs.alvestrand.titrax-sheet application/prs.cww cww -# application/prs.hpub+zip -# application/prs.nprend -# application/prs.plucker -# application/prs.rdf-xml-crypt -# application/prs.xsf+xml application/pskc+xml pskcxml -# application/qsig -# application/raptorfec -# application/rdap+json application/rdf+xml rdf application/reginfo+xml rif application/relax-ng-compact-syntax rnc -# application/remote-printing -# application/reputon+json application/resource-lists+xml rl application/resource-lists-diff+xml rld -# application/rfc+xml -# application/riscos -# application/rlmi+xml application/rls-services+xml rs application/rpki-ghostbusters gbr application/rpki-manifest mft application/rpki-roa roa -# application/rpki-updown application/rsd+xml rsd application/rss+xml rss application/rtf rtf -# application/rtploopback -# application/rtx -# application/samlassertion+xml -# application/samlmetadata+xml application/sbml+xml sbml -# application/scaip+xml -# application/scim+json application/scvp-cv-request scq application/scvp-cv-response scs application/scvp-vp-request spq application/scvp-vp-response spp application/sdp sdp -# application/sep+xml -# application/sep-exi -# application/session-info -# application/set-payment application/set-payment-initiation setpay -# application/set-registration application/set-registration-initiation setreg -# application/sgml -# application/sgml-open-catalog application/shf+xml shf -# application/sieve -# application/simple-filter+xml -# application/simple-message-summary -# application/simplesymbolcontainer -# application/slate -# application/smil application/smil+xml smi smil -# application/smpte336m -# application/soap+fastinfoset -# application/soap+xml application/sparql-query rq application/sparql-results+xml srx -# application/spirits-event+xml -# application/sql application/srgs gram application/srgs+xml grxml application/sru+xml sru application/ssdl+xml ssdl application/ssml+xml ssml -# application/tamp-apex-update -# application/tamp-apex-update-confirm -# application/tamp-community-update -# application/tamp-community-update-confirm -# application/tamp-error -# application/tamp-sequence-adjust -# application/tamp-sequence-adjust-confirm -# application/tamp-status-query -# application/tamp-status-response -# application/tamp-update -# application/tamp-update-confirm application/tei+xml tei teicorpus application/thraud+xml tfi -# application/timestamp-query -# application/timestamp-reply application/timestamped-data tsd -# application/ttml+xml -# application/tve-trigger -# application/ulpfec -# application/urc-grpsheet+xml -# application/urc-ressheet+xml -# application/urc-targetdesc+xml -# application/urc-uisocketdesc+xml -# application/vcard+json -# application/vcard+xml -# application/vemmi -# application/vividence.scriptfile -# application/vnd.3gpp-prose+xml -# application/vnd.3gpp-prose-pc3ch+xml -# application/vnd.3gpp.access-transfer-events+xml -# application/vnd.3gpp.bsf+xml -# application/vnd.3gpp.mid-call+xml application/vnd.3gpp.pic-bw-large plb application/vnd.3gpp.pic-bw-small psb application/vnd.3gpp.pic-bw-var pvb -# application/vnd.3gpp.sms -# application/vnd.3gpp.sms+xml -# application/vnd.3gpp.srvcc-ext+xml -# application/vnd.3gpp.srvcc-info+xml -# application/vnd.3gpp.state-and-event-info+xml -# application/vnd.3gpp.ussd+xml -# application/vnd.3gpp2.bcmcsinfo+xml -# application/vnd.3gpp2.sms application/vnd.3gpp2.tcap tcap -# application/vnd.3lightssoftware.imagescal application/vnd.3m.post-it-notes pwn application/vnd.accpac.simply.aso aso application/vnd.accpac.simply.imp imp application/vnd.acucobol acu application/vnd.acucorp atc acutc application/vnd.adobe.air-application-installer-package+zip air -# application/vnd.adobe.flash.movie application/vnd.adobe.formscentral.fcdt fcdt application/vnd.adobe.fxp fxp fxpl -# application/vnd.adobe.partial-upload application/vnd.adobe.xdp+xml xdp application/vnd.adobe.xfdf xfdf -# application/vnd.aether.imp -# application/vnd.ah-barcode application/vnd.ahead.space ahead application/vnd.airzip.filesecure.azf azf application/vnd.airzip.filesecure.azs azs application/vnd.amazon.ebook azw -# application/vnd.amazon.mobi8-ebook application/vnd.americandynamics.acc acc application/vnd.amiga.ami ami -# application/vnd.amundsen.maze+xml application/vnd.android.package-archive apk -# application/vnd.anki application/vnd.anser-web-certificate-issue-initiation cii application/vnd.anser-web-funds-transfer-initiation fti application/vnd.antix.game-component atx -# application/vnd.apache.thrift.binary -# application/vnd.apache.thrift.compact -# application/vnd.apache.thrift.json -# application/vnd.api+json application/vnd.apple.installer+xml mpkg application/vnd.apple.mpegurl m3u8 -# application/vnd.arastra.swi application/vnd.aristanetworks.swi swi -# application/vnd.artsquare application/vnd.astraea-software.iota iota application/vnd.audiograph aep -# application/vnd.autopackage -# application/vnd.avistar+xml -# application/vnd.balsamiq.bmml+xml -# application/vnd.balsamiq.bmpr -# application/vnd.bekitzur-stech+json -# application/vnd.biopax.rdf+xml application/vnd.blueice.multipass mpm -# application/vnd.bluetooth.ep.oob -# application/vnd.bluetooth.le.oob application/vnd.bmi bmi application/vnd.businessobjects rep -# application/vnd.cab-jscript -# application/vnd.canon-cpdl -# application/vnd.canon-lips -# application/vnd.cendio.thinlinc.clientconf -# application/vnd.century-systems.tcp_stream application/vnd.chemdraw+xml cdxml -# application/vnd.chess-pgn application/vnd.chipnuts.karaoke-mmd mmd application/vnd.cinderella cdy -# application/vnd.cirpack.isdn-ext -# application/vnd.citationstyles.style+xml application/vnd.claymore cla application/vnd.cloanto.rp9 rp9 application/vnd.clonk.c4group c4g c4d c4f c4p c4u application/vnd.cluetrust.cartomobile-config c11amc application/vnd.cluetrust.cartomobile-config-pkg c11amz -# application/vnd.coffeescript -# application/vnd.collection+json -# application/vnd.collection.doc+json -# application/vnd.collection.next+json -# application/vnd.comicbook+zip -# application/vnd.commerce-battelle application/vnd.commonspace csp application/vnd.contact.cmsg cdbcmsg -# application/vnd.coreos.ignition+json application/vnd.cosmocaller cmc application/vnd.crick.clicker clkx application/vnd.crick.clicker.keyboard clkk @@ -454,119 +172,39 @@ application/vnd.crick.clicker.template clkt application/vnd.crick.clicker.wordbank clkw application/vnd.criticaltools.wbs+xml wbs application/vnd.ctc-posml pml -# application/vnd.ctct.ws+xml -# application/vnd.cups-pdf -# application/vnd.cups-postscript application/vnd.cups-ppd ppd -# application/vnd.cups-raster -# application/vnd.cups-raw -# application/vnd.curl application/vnd.curl.car car application/vnd.curl.pcurl pcurl -# application/vnd.cyan.dean.root+xml -# application/vnd.cybank application/vnd.dart dart application/vnd.data-vision.rdz rdz -# application/vnd.debian.binary-package application/vnd.dece.data uvf uvvf uvd uvvd application/vnd.dece.ttml+xml uvt uvvt application/vnd.dece.unspecified uvx uvvx application/vnd.dece.zip uvz uvvz application/vnd.denovo.fcselayout-link fe_launch -# application/vnd.desmume.movie -# application/vnd.dir-bi.plate-dl-nosuffix -# application/vnd.dm.delegation+xml application/vnd.dna dna -# application/vnd.document+json application/vnd.dolby.mlp mlp -# application/vnd.dolby.mobile.1 -# application/vnd.dolby.mobile.2 -# application/vnd.doremir.scorecloud-binary-document application/vnd.dpgraph dpg application/vnd.dreamfactory dfac -# application/vnd.drive+json application/vnd.ds-keypoint kpxx -# application/vnd.dtg.local -# application/vnd.dtg.local.flash -# application/vnd.dtg.local.html application/vnd.dvb.ait ait -# application/vnd.dvb.dvbj -# application/vnd.dvb.esgcontainer -# application/vnd.dvb.ipdcdftnotifaccess -# application/vnd.dvb.ipdcesgaccess -# application/vnd.dvb.ipdcesgaccess2 -# application/vnd.dvb.ipdcesgpdd -# application/vnd.dvb.ipdcroaming -# application/vnd.dvb.iptv.alfec-base -# application/vnd.dvb.iptv.alfec-enhancement -# application/vnd.dvb.notif-aggregate-root+xml -# application/vnd.dvb.notif-container+xml -# application/vnd.dvb.notif-generic+xml -# application/vnd.dvb.notif-ia-msglist+xml -# application/vnd.dvb.notif-ia-registration-request+xml -# application/vnd.dvb.notif-ia-registration-response+xml -# application/vnd.dvb.notif-init+xml -# application/vnd.dvb.pfr application/vnd.dvb.service svc -# application/vnd.dxr application/vnd.dynageo geo -# application/vnd.dzr -# application/vnd.easykaraoke.cdgdownload -# application/vnd.ecdis-update application/vnd.ecowin.chart mag -# application/vnd.ecowin.filerequest -# application/vnd.ecowin.fileupdate -# application/vnd.ecowin.series -# application/vnd.ecowin.seriesrequest -# application/vnd.ecowin.seriesupdate -# application/vnd.emclient.accessrequest+xml application/vnd.enliven nml -# application/vnd.enphase.envoy -# application/vnd.eprints.data+xml application/vnd.epson.esf esf application/vnd.epson.msf msf application/vnd.epson.quickanime qam application/vnd.epson.salt slt application/vnd.epson.ssf ssf -# application/vnd.ericsson.quickcall application/vnd.eszigno3+xml es3 et3 -# application/vnd.etsi.aoc+xml -# application/vnd.etsi.asic-e+zip -# application/vnd.etsi.asic-s+zip -# application/vnd.etsi.cug+xml -# application/vnd.etsi.iptvcommand+xml -# application/vnd.etsi.iptvdiscovery+xml -# application/vnd.etsi.iptvprofile+xml -# application/vnd.etsi.iptvsad-bc+xml -# application/vnd.etsi.iptvsad-cod+xml -# application/vnd.etsi.iptvsad-npvr+xml -# application/vnd.etsi.iptvservice+xml -# application/vnd.etsi.iptvsync+xml -# application/vnd.etsi.iptvueprofile+xml -# application/vnd.etsi.mcid+xml -# application/vnd.etsi.mheg5 -# application/vnd.etsi.overload-control-policy-dataset+xml -# application/vnd.etsi.pstn+xml -# application/vnd.etsi.sci+xml -# application/vnd.etsi.simservs+xml -# application/vnd.etsi.timestamp-token -# application/vnd.etsi.tsl+xml -# application/vnd.etsi.tsl.der -# application/vnd.eudora.data application/vnd.ezpix-album ez2 application/vnd.ezpix-package ez3 -# application/vnd.f-secure.mobile -# application/vnd.fastcopy-disk-image application/vnd.fdf fdf application/vnd.fdsn.mseed mseed application/vnd.fdsn.seed seed dataless -# application/vnd.ffsns -# application/vnd.filmit.zfc -# application/vnd.fints -# application/vnd.firemonkeys.cloudcell application/vnd.flographit gph application/vnd.fluxtime.clip ftc -# application/vnd.font-fontforge-sfd application/vnd.framemaker fm frame maker book application/vnd.frogans.fnc fnc application/vnd.frogans.ltf ltf @@ -576,35 +214,22 @@ application/vnd.fujitsu.oasys2 oa2 application/vnd.fujitsu.oasys3 oa3 application/vnd.fujitsu.oasysgp fg5 application/vnd.fujitsu.oasysprs bh2 -# application/vnd.fujixerox.art-ex -# application/vnd.fujixerox.art4 application/vnd.fujixerox.ddd ddd application/vnd.fujixerox.docuworks xdw application/vnd.fujixerox.docuworks.binder xbd -# application/vnd.fujixerox.docuworks.container -# application/vnd.fujixerox.hbpl -# application/vnd.fut-misnet application/vnd.fuzzysheet fzs application/vnd.genomatix.tuxedo txd -# application/vnd.geo+json -# application/vnd.geocube+xml application/vnd.geogebra.file ggb +application/vnd.geogebra.slides ggs application/vnd.geogebra.tool ggt application/vnd.geometry-explorer gex gre application/vnd.geonext gxt application/vnd.geoplan g2w application/vnd.geospace g3w -# application/vnd.gerber -# application/vnd.globalplatform.card-content-mgt -# application/vnd.globalplatform.card-content-mgt-response application/vnd.gmx gmx application/vnd.google-earth.kml+xml kml application/vnd.google-earth.kmz kmz -# application/vnd.gov.sk.e-form+xml -# application/vnd.gov.sk.e-form+zip -# application/vnd.gov.sk.xmldatacontainer+xml application/vnd.grafeq gqf gqs -# application/vnd.gridmp application/vnd.groove-account gac application/vnd.groove-help ghf application/vnd.groove-identity-message gim @@ -612,13 +237,9 @@ application/vnd.groove-injector grv application/vnd.groove-tool-message gtm application/vnd.groove-tool-template tpl application/vnd.groove-vcard vcg -# application/vnd.hal+json application/vnd.hal+xml hal application/vnd.handheld-entertainment+xml zmm application/vnd.hbci hbci -# application/vnd.hcl-bireports -# application/vnd.hdt -# application/vnd.heroku+json application/vnd.hhe.lesson-player les application/vnd.hp-hpgl hpgl application/vnd.hp-hpid hpid @@ -626,66 +247,28 @@ application/vnd.hp-hps hps application/vnd.hp-jlyt jlt application/vnd.hp-pcl pcl application/vnd.hp-pclxl pclxl -# application/vnd.httphone application/vnd.hydrostatix.sof-data sfd-hdstx -# application/vnd.hyperdrive+json -# application/vnd.hzn-3d-crossword -# application/vnd.ibm.afplinedata -# application/vnd.ibm.electronic-media application/vnd.ibm.minipay mpy application/vnd.ibm.modcap afp listafp list3820 application/vnd.ibm.rights-management irm application/vnd.ibm.secure-container sc application/vnd.iccprofile icc icm -# application/vnd.ieee.1905 application/vnd.igloader igl application/vnd.immervision-ivp ivp application/vnd.immervision-ivu ivu -# application/vnd.ims.imsccv1p1 -# application/vnd.ims.imsccv1p2 -# application/vnd.ims.imsccv1p3 -# application/vnd.ims.lis.v2.result+json -# application/vnd.ims.lti.v2.toolconsumerprofile+json -# application/vnd.ims.lti.v2.toolproxy+json -# application/vnd.ims.lti.v2.toolproxy.id+json -# application/vnd.ims.lti.v2.toolsettings+json -# application/vnd.ims.lti.v2.toolsettings.simple+json -# application/vnd.informedcontrol.rms+xml -# application/vnd.informix-visionary -# application/vnd.infotech.project -# application/vnd.infotech.project+xml -# application/vnd.innopath.wamp.notification application/vnd.insors.igm igm application/vnd.intercon.formnet xpw xpx application/vnd.intergeo i2g -# application/vnd.intertrust.digibox -# application/vnd.intertrust.nncp application/vnd.intu.qbo qbo application/vnd.intu.qfx qfx -# application/vnd.iptc.g2.catalogitem+xml -# application/vnd.iptc.g2.conceptitem+xml -# application/vnd.iptc.g2.knowledgeitem+xml -# application/vnd.iptc.g2.newsitem+xml -# application/vnd.iptc.g2.newsmessage+xml -# application/vnd.iptc.g2.packageitem+xml -# application/vnd.iptc.g2.planningitem+xml application/vnd.ipunplugged.rcprofile rcprofile application/vnd.irepository.package+xml irp application/vnd.is-xpr xpr application/vnd.isac.fcs fcs application/vnd.jam jam -# application/vnd.japannet-directory-service -# application/vnd.japannet-jpnstore-wakeup -# application/vnd.japannet-payment-wakeup -# application/vnd.japannet-registration -# application/vnd.japannet-registration-wakeup -# application/vnd.japannet-setstore-wakeup -# application/vnd.japannet-verification -# application/vnd.japannet-verification-wakeup application/vnd.jcp.javame.midlet-rms rms application/vnd.jisp jisp application/vnd.joost.joda-archive joda -# application/vnd.jsk.isdn-ngn application/vnd.kahootz ktz ktr application/vnd.kde.karbon karbon application/vnd.kde.kchart chrt @@ -701,7 +284,6 @@ application/vnd.kinar kne knp application/vnd.koan skp skd skt skm application/vnd.kodak-descriptor sse application/vnd.las.las+xml lasxml -# application/vnd.liberty-request+xml application/vnd.llamagraphics.life-balance.desktop lbd application/vnd.llamagraphics.life-balance.exchange+xml lbe application/vnd.lotus-1-2-3 123 @@ -712,27 +294,14 @@ application/vnd.lotus-organizer org application/vnd.lotus-screencam scm application/vnd.lotus-wordpro lwp application/vnd.macports.portpkg portpkg -# application/vnd.mapbox-vector-tile -# application/vnd.marlin.drm.actiontoken+xml -# application/vnd.marlin.drm.conftoken+xml -# application/vnd.marlin.drm.license+xml -# application/vnd.marlin.drm.mdcf -# application/vnd.mason+json -# application/vnd.maxmind.maxmind-db application/vnd.mcd mcd application/vnd.medcalcdata mc1 application/vnd.mediastation.cdkey cdkey -# application/vnd.meridian-slingshot application/vnd.mfer mwf application/vnd.mfmp mfm -# application/vnd.micro+json application/vnd.micrografx.flo flo application/vnd.micrografx.igx igx -# application/vnd.microsoft.portable-executable -# application/vnd.miele+json application/vnd.mif mif -# application/vnd.minisoft-hp3000-save -# application/vnd.mitsubishi.misty-guard.trustweb application/vnd.mobius.daf daf application/vnd.mobius.dis dis application/vnd.mobius.mbk mbk @@ -742,20 +311,9 @@ application/vnd.mobius.plc plc application/vnd.mobius.txf txf application/vnd.mophun.application mpn application/vnd.mophun.certificate mpc -# application/vnd.motorola.flexsuite -# application/vnd.motorola.flexsuite.adsi -# application/vnd.motorola.flexsuite.fis -# application/vnd.motorola.flexsuite.gotap -# application/vnd.motorola.flexsuite.kmr -# application/vnd.motorola.flexsuite.ttc -# application/vnd.motorola.flexsuite.wem -# application/vnd.motorola.iprm application/vnd.mozilla.xul+xml xul -# application/vnd.ms-3mfdocument application/vnd.ms-artgalry cil -# application/vnd.ms-asf application/vnd.ms-cab-compressed cab -# application/vnd.ms-color.iccprofile application/vnd.ms-excel xls xlm xla xlc xlt xlw application/vnd.ms-excel.addin.macroenabled.12 xlam application/vnd.ms-excel.sheet.binary.macroenabled.12 xlsb @@ -765,81 +323,37 @@ application/vnd.ms-fontobject eot application/vnd.ms-htmlhelp chm application/vnd.ms-ims ims application/vnd.ms-lrm lrm -# application/vnd.ms-office.activex+xml application/vnd.ms-officetheme thmx -# application/vnd.ms-opentype -# application/vnd.ms-package.obfuscated-opentype application/vnd.ms-pki.seccat cat application/vnd.ms-pki.stl stl -# application/vnd.ms-playready.initiator+xml application/vnd.ms-powerpoint ppt pps pot application/vnd.ms-powerpoint.addin.macroenabled.12 ppam application/vnd.ms-powerpoint.presentation.macroenabled.12 pptm application/vnd.ms-powerpoint.slide.macroenabled.12 sldm application/vnd.ms-powerpoint.slideshow.macroenabled.12 ppsm application/vnd.ms-powerpoint.template.macroenabled.12 potm -# application/vnd.ms-printdevicecapabilities+xml -# application/vnd.ms-printing.printticket+xml -# application/vnd.ms-printschematicket+xml application/vnd.ms-project mpp mpt -# application/vnd.ms-tnef -# application/vnd.ms-windows.devicepairing -# application/vnd.ms-windows.nwprinting.oob -# application/vnd.ms-windows.printerpairing -# application/vnd.ms-windows.wsd.oob -# application/vnd.ms-wmdrm.lic-chlg-req -# application/vnd.ms-wmdrm.lic-resp -# application/vnd.ms-wmdrm.meter-chlg-req -# application/vnd.ms-wmdrm.meter-resp application/vnd.ms-word.document.macroenabled.12 docm application/vnd.ms-word.template.macroenabled.12 dotm application/vnd.ms-works wps wks wcm wdb application/vnd.ms-wpl wpl application/vnd.ms-xpsdocument xps -# application/vnd.msa-disk-image application/vnd.mseq mseq -# application/vnd.msign -# application/vnd.multiad.creator -# application/vnd.multiad.creator.cif -# application/vnd.music-niff application/vnd.musician mus application/vnd.muvee.style msty application/vnd.mynfc taglet -# application/vnd.ncd.control -# application/vnd.ncd.reference -# application/vnd.nervana -# application/vnd.netfpx application/vnd.neurolanguage.nlu nlu -# application/vnd.nintendo.nitro.rom -# application/vnd.nintendo.snes.rom application/vnd.nitf ntf nitf application/vnd.noblenet-directory nnd application/vnd.noblenet-sealer nns application/vnd.noblenet-web nnw -# application/vnd.nokia.catalogs -# application/vnd.nokia.conml+wbxml -# application/vnd.nokia.conml+xml -# application/vnd.nokia.iptv.config+xml -# application/vnd.nokia.isds-radio-presets -# application/vnd.nokia.landmark+wbxml -# application/vnd.nokia.landmark+xml -# application/vnd.nokia.landmarkcollection+xml -# application/vnd.nokia.n-gage.ac+xml application/vnd.nokia.n-gage.data ngdat application/vnd.nokia.n-gage.symbian.install n-gage -# application/vnd.nokia.ncd -# application/vnd.nokia.pcd+wbxml -# application/vnd.nokia.pcd+xml application/vnd.nokia.radio-preset rpst application/vnd.nokia.radio-presets rpss application/vnd.novadigm.edm edm application/vnd.novadigm.edx edx application/vnd.novadigm.ext ext -# application/vnd.ntt-local.content-share -# application/vnd.ntt-local.file-transfer -# application/vnd.ntt-local.ogw_remote-access -# application/vnd.ntt-local.sip-ta_remote -# application/vnd.ntt-local.sip-ta_tcp_stream application/vnd.oasis.opendocument.chart odc application/vnd.oasis.opendocument.chart-template otc application/vnd.oasis.opendocument.database odb @@ -857,224 +371,42 @@ application/vnd.oasis.opendocument.text odt application/vnd.oasis.opendocument.text-master odm application/vnd.oasis.opendocument.text-template ott application/vnd.oasis.opendocument.text-web oth -# application/vnd.obn -# application/vnd.oftn.l10n+json -# application/vnd.oipf.contentaccessdownload+xml -# application/vnd.oipf.contentaccessstreaming+xml -# application/vnd.oipf.cspg-hexbinary -# application/vnd.oipf.dae.svg+xml -# application/vnd.oipf.dae.xhtml+xml -# application/vnd.oipf.mippvcontrolmessage+xml -# application/vnd.oipf.pae.gem -# application/vnd.oipf.spdiscovery+xml -# application/vnd.oipf.spdlist+xml -# application/vnd.oipf.ueprofile+xml -# application/vnd.oipf.userprofile+xml application/vnd.olpc-sugar xo -# application/vnd.oma-scws-config -# application/vnd.oma-scws-http-request -# application/vnd.oma-scws-http-response -# application/vnd.oma.bcast.associated-procedure-parameter+xml -# application/vnd.oma.bcast.drm-trigger+xml -# application/vnd.oma.bcast.imd+xml -# application/vnd.oma.bcast.ltkm -# application/vnd.oma.bcast.notification+xml -# application/vnd.oma.bcast.provisioningtrigger -# application/vnd.oma.bcast.sgboot -# application/vnd.oma.bcast.sgdd+xml -# application/vnd.oma.bcast.sgdu -# application/vnd.oma.bcast.simple-symbol-container -# application/vnd.oma.bcast.smartcard-trigger+xml -# application/vnd.oma.bcast.sprov+xml -# application/vnd.oma.bcast.stkm -# application/vnd.oma.cab-address-book+xml -# application/vnd.oma.cab-feature-handler+xml -# application/vnd.oma.cab-pcc+xml -# application/vnd.oma.cab-subs-invite+xml -# application/vnd.oma.cab-user-prefs+xml -# application/vnd.oma.dcd -# application/vnd.oma.dcdc application/vnd.oma.dd2+xml dd2 -# application/vnd.oma.drm.risd+xml -# application/vnd.oma.group-usage-list+xml -# application/vnd.oma.lwm2m+json -# application/vnd.oma.lwm2m+tlv -# application/vnd.oma.pal+xml -# application/vnd.oma.poc.detailed-progress-report+xml -# application/vnd.oma.poc.final-report+xml -# application/vnd.oma.poc.groups+xml -# application/vnd.oma.poc.invocation-descriptor+xml -# application/vnd.oma.poc.optimized-progress-report+xml -# application/vnd.oma.push -# application/vnd.oma.scidm.messages+xml -# application/vnd.oma.xcap-directory+xml -# application/vnd.omads-email+xml -# application/vnd.omads-file+xml -# application/vnd.omads-folder+xml -# application/vnd.omaloc-supl-init -# application/vnd.onepager -# application/vnd.openblox.game+xml -# application/vnd.openblox.game-binary -# application/vnd.openeye.oeb application/vnd.openofficeorg.extension oxt -# application/vnd.openxmlformats-officedocument.custom-properties+xml -# application/vnd.openxmlformats-officedocument.customxmlproperties+xml -# application/vnd.openxmlformats-officedocument.drawing+xml -# application/vnd.openxmlformats-officedocument.drawingml.chart+xml -# application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml -# application/vnd.openxmlformats-officedocument.drawingml.diagramcolors+xml -# application/vnd.openxmlformats-officedocument.drawingml.diagramdata+xml -# application/vnd.openxmlformats-officedocument.drawingml.diagramlayout+xml -# application/vnd.openxmlformats-officedocument.drawingml.diagramstyle+xml -# application/vnd.openxmlformats-officedocument.extended-properties+xml -# application/vnd.openxmlformats-officedocument.presentationml.commentauthors+xml -# application/vnd.openxmlformats-officedocument.presentationml.comments+xml -# application/vnd.openxmlformats-officedocument.presentationml.handoutmaster+xml -# application/vnd.openxmlformats-officedocument.presentationml.notesmaster+xml -# application/vnd.openxmlformats-officedocument.presentationml.notesslide+xml application/vnd.openxmlformats-officedocument.presentationml.presentation pptx -# application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml -# application/vnd.openxmlformats-officedocument.presentationml.presprops+xml application/vnd.openxmlformats-officedocument.presentationml.slide sldx -# application/vnd.openxmlformats-officedocument.presentationml.slide+xml -# application/vnd.openxmlformats-officedocument.presentationml.slidelayout+xml -# application/vnd.openxmlformats-officedocument.presentationml.slidemaster+xml application/vnd.openxmlformats-officedocument.presentationml.slideshow ppsx -# application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml -# application/vnd.openxmlformats-officedocument.presentationml.slideupdateinfo+xml -# application/vnd.openxmlformats-officedocument.presentationml.tablestyles+xml -# application/vnd.openxmlformats-officedocument.presentationml.tags+xml application/vnd.openxmlformats-officedocument.presentationml.template potx -# application/vnd.openxmlformats-officedocument.presentationml.template.main+xml -# application/vnd.openxmlformats-officedocument.presentationml.viewprops+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.calcchain+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.externallink+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcachedefinition+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcacherecords+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.pivottable+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.querytable+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.revisionheaders+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.revisionlog+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.sharedstrings+xml application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx -# application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.sheetmetadata+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.tablesinglecells+xml application/vnd.openxmlformats-officedocument.spreadsheetml.template xltx -# application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.usernames+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.volatiledependencies+xml -# application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml -# application/vnd.openxmlformats-officedocument.theme+xml -# application/vnd.openxmlformats-officedocument.themeoverride+xml -# application/vnd.openxmlformats-officedocument.vmldrawing -# application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml application/vnd.openxmlformats-officedocument.wordprocessingml.document docx -# application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml -# application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml -# application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml -# application/vnd.openxmlformats-officedocument.wordprocessingml.fonttable+xml -# application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml -# application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml -# application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml -# application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml -# application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml application/vnd.openxmlformats-officedocument.wordprocessingml.template dotx -# application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml -# application/vnd.openxmlformats-officedocument.wordprocessingml.websettings+xml -# application/vnd.openxmlformats-package.core-properties+xml -# application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml -# application/vnd.openxmlformats-package.relationships+xml -# application/vnd.oracle.resource+json -# application/vnd.orange.indata -# application/vnd.osa.netdeploy application/vnd.osgeo.mapguide.package mgp -# application/vnd.osgi.bundle application/vnd.osgi.dp dp application/vnd.osgi.subsystem esa -# application/vnd.otps.ct-kip+xml -# application/vnd.oxli.countgraph -# application/vnd.pagerduty+json application/vnd.palm pdb pqa oprc -# application/vnd.panoply -# application/vnd.paos.xml application/vnd.pawaafile paw -# application/vnd.pcos application/vnd.pg.format str application/vnd.pg.osasli ei6 -# application/vnd.piaccess.application-licence application/vnd.picsel efif application/vnd.pmi.widget wg -# application/vnd.poc.group-advertisement+xml application/vnd.pocketlearn plf application/vnd.powerbuilder6 pbd -# application/vnd.powerbuilder6-s -# application/vnd.powerbuilder7 -# application/vnd.powerbuilder7-s -# application/vnd.powerbuilder75 -# application/vnd.powerbuilder75-s -# application/vnd.preminet application/vnd.previewsystems.box box application/vnd.proteus.magazine mgz application/vnd.publishare-delta-tree qps application/vnd.pvi.ptid1 ptid -# application/vnd.pwg-multiplexed -# application/vnd.pwg-xhtml-print+xml -# application/vnd.qualcomm.brew-app-res -# application/vnd.quarantainenet application/vnd.quark.quarkxpress qxd qxt qwd qwt qxl qxb -# application/vnd.quobject-quoxdocument -# application/vnd.radisys.moml+xml -# application/vnd.radisys.msml+xml -# application/vnd.radisys.msml-audit+xml -# application/vnd.radisys.msml-audit-conf+xml -# application/vnd.radisys.msml-audit-conn+xml -# application/vnd.radisys.msml-audit-dialog+xml -# application/vnd.radisys.msml-audit-stream+xml -# application/vnd.radisys.msml-conf+xml -# application/vnd.radisys.msml-dialog+xml -# application/vnd.radisys.msml-dialog-base+xml -# application/vnd.radisys.msml-dialog-fax-detect+xml -# application/vnd.radisys.msml-dialog-fax-sendrecv+xml -# application/vnd.radisys.msml-dialog-group+xml -# application/vnd.radisys.msml-dialog-speech+xml -# application/vnd.radisys.msml-dialog-transform+xml -# application/vnd.rainstor.data -# application/vnd.rapid -# application/vnd.rar application/vnd.realvnc.bed bed application/vnd.recordare.musicxml mxl application/vnd.recordare.musicxml+xml musicxml -# application/vnd.renlearn.rlprint application/vnd.rig.cryptonote cryptonote application/vnd.rim.cod cod application/vnd.rn-realmedia rm application/vnd.rn-realmedia-vbr rmvb application/vnd.route66.link66+xml link66 -# application/vnd.rs-274x -# application/vnd.ruckus.download -# application/vnd.s3sms application/vnd.sailingtracker.track st -# application/vnd.sbm.cid -# application/vnd.sbm.mid2 -# application/vnd.scribus -# application/vnd.sealed.3df -# application/vnd.sealed.csf -# application/vnd.sealed.doc -# application/vnd.sealed.eml -# application/vnd.sealed.mht -# application/vnd.sealed.net -# application/vnd.sealed.ppt -# application/vnd.sealed.tiff -# application/vnd.sealed.xls -# application/vnd.sealedmedia.softseal.html -# application/vnd.sealedmedia.softseal.pdf application/vnd.seemail see application/vnd.sema sema application/vnd.semd semd @@ -1084,18 +416,11 @@ application/vnd.shana.informed.formtemplate itp application/vnd.shana.informed.interchange iif application/vnd.shana.informed.package ipk application/vnd.simtech-mindmapper twd twds -# application/vnd.siren+json application/vnd.smaf mmf -# application/vnd.smart.notebook application/vnd.smart.teacher teacher -# application/vnd.software602.filler.form+xml -# application/vnd.software602.filler.form-xml-zip application/vnd.solent.sdkm+xml sdkm sdkd application/vnd.spotfire.dxp dxp application/vnd.spotfire.sfs sfs -# application/vnd.sss-cod -# application/vnd.sss-dtf -# application/vnd.sss-ntf application/vnd.stardivision.calc sdc application/vnd.stardivision.draw sda application/vnd.stardivision.impress sdd @@ -1104,8 +429,6 @@ application/vnd.stardivision.writer sdw vor application/vnd.stardivision.writer-global sgl application/vnd.stepmania.package smzip application/vnd.stepmania.stepchart sm -# application/vnd.street-stream -# application/vnd.sun.wadl+xml application/vnd.sun.xml.calc sxc application/vnd.sun.xml.calc.template stc application/vnd.sun.xml.draw sxd @@ -1118,117 +441,54 @@ application/vnd.sun.xml.writer.global sxg application/vnd.sun.xml.writer.template stw application/vnd.sus-calendar sus susp application/vnd.svd svd -# application/vnd.swiftview-ics application/vnd.symbian.install sis sisx application/vnd.syncml+xml xsm application/vnd.syncml.dm+wbxml bdm application/vnd.syncml.dm+xml xdm -# application/vnd.syncml.dm.notification -# application/vnd.syncml.dmddf+wbxml -# application/vnd.syncml.dmddf+xml -# application/vnd.syncml.dmtnds+wbxml -# application/vnd.syncml.dmtnds+xml -# application/vnd.syncml.ds.notification application/vnd.tao.intent-module-archive tao application/vnd.tcpdump.pcap pcap cap dmp -# application/vnd.tmd.mediaflex.api+xml -# application/vnd.tml application/vnd.tmobile-livetv tmo application/vnd.trid.tpt tpt application/vnd.triscape.mxs mxs application/vnd.trueapp tra -# application/vnd.truedoc -# application/vnd.ubisoft.webplayer application/vnd.ufdl ufd ufdl application/vnd.uiq.theme utz application/vnd.umajin umj application/vnd.unity unityweb application/vnd.uoml+xml uoml -# application/vnd.uplanet.alert -# application/vnd.uplanet.alert-wbxml -# application/vnd.uplanet.bearer-choice -# application/vnd.uplanet.bearer-choice-wbxml -# application/vnd.uplanet.cacheop -# application/vnd.uplanet.cacheop-wbxml -# application/vnd.uplanet.channel -# application/vnd.uplanet.channel-wbxml -# application/vnd.uplanet.list -# application/vnd.uplanet.list-wbxml -# application/vnd.uplanet.listcmd -# application/vnd.uplanet.listcmd-wbxml -# application/vnd.uplanet.signal -# application/vnd.uri-map -# application/vnd.valve.source.material application/vnd.vcx vcx -# application/vnd.vd-study -# application/vnd.vectorworks -# application/vnd.vel+json -# application/vnd.verimatrix.vcas -# application/vnd.vidsoft.vidconference application/vnd.visio vsd vst vss vsw application/vnd.visionary vis -# application/vnd.vividence.scriptfile application/vnd.vsf vsf -# application/vnd.wap.sic -# application/vnd.wap.slc application/vnd.wap.wbxml wbxml application/vnd.wap.wmlc wmlc application/vnd.wap.wmlscriptc wmlsc application/vnd.webturbo wtb -# application/vnd.wfa.p2p -# application/vnd.wfa.wsc -# application/vnd.windows.devicepairing -# application/vnd.wmc -# application/vnd.wmf.bootstrap -# application/vnd.wolfram.mathematica -# application/vnd.wolfram.mathematica.package application/vnd.wolfram.player nbp application/vnd.wordperfect wpd application/vnd.wqd wqd -# application/vnd.wrq-hp3000-labelled application/vnd.wt.stf stf -# application/vnd.wv.csp+wbxml -# application/vnd.wv.csp+xml -# application/vnd.wv.ssp+xml -# application/vnd.xacml+json application/vnd.xara xar application/vnd.xfdl xfdl -# application/vnd.xfdl.webform -# application/vnd.xmi+xml -# application/vnd.xmpie.cpkg -# application/vnd.xmpie.dpkg -# application/vnd.xmpie.plan -# application/vnd.xmpie.ppkg -# application/vnd.xmpie.xlim application/vnd.yamaha.hv-dic hvd application/vnd.yamaha.hv-script hvs application/vnd.yamaha.hv-voice hvp application/vnd.yamaha.openscoreformat osf application/vnd.yamaha.openscoreformat.osfpvg+xml osfpvg -# application/vnd.yamaha.remote-setup application/vnd.yamaha.smaf-audio saf application/vnd.yamaha.smaf-phrase spf -# application/vnd.yamaha.through-ngn -# application/vnd.yamaha.tunnel-udpencap -# application/vnd.yaoweme application/vnd.yellowriver-custom-menu cmp application/vnd.zul zir zirz application/vnd.zzazz.deck+xml zaz application/voicexml+xml vxml -# application/vq-rtcpxr -# application/watcherinfo+xml -# application/whoispp-query -# application/whoispp-response +application/wasm wasm application/widget wgt application/winhlp hlp -# application/wita -# application/wordperfect5.1 application/wsdl+xml wsdl application/wspolicy+xml wspolicy application/x-7z-compressed 7z application/x-abiword abw application/x-ace-compressed ace -# application/x-amf application/x-apple-diskimage dmg application/x-authorware-bin aab x32 u32 vox application/x-authorware-map aam @@ -1243,7 +503,6 @@ application/x-cdlink vcd application/x-cfs-compressed cfs application/x-chat chat application/x-chess-pgn pgn -# application/x-compress application/x-conference nsc application/x-cpio cpio application/x-csh csh @@ -1258,19 +517,11 @@ application/x-dvi dvi application/x-envoy evy application/x-eva eva application/x-font-bdf bdf -# application/x-font-dos -# application/x-font-framemaker application/x-font-ghostscript gsf -# application/x-font-libgrx application/x-font-linux-psf psf -application/x-font-otf otf application/x-font-pcf pcf application/x-font-snf snf -# application/x-font-speedo -# application/x-font-sunos-news -application/x-font-ttf ttf ttc application/x-font-type1 pfa pfb pfm afm -# application/x-font-vfont application/x-freearc arc application/x-futuresplash spl application/x-gca-compressed gca @@ -1278,7 +529,6 @@ application/x-glulx ulx application/x-gnumeric gnumeric application/x-gramps-xml gramps application/x-gtar gtar -# application/x-gzip application/x-hdf hdf application/x-install-instructions install application/x-iso9660-image iso @@ -1331,32 +581,18 @@ application/x-texinfo texinfo texi application/x-tgif obj application/x-ustar ustar application/x-wais-source src -# application/x-www-form-urlencoded application/x-x509-ca-cert der crt application/x-xfig fig application/x-xliff+xml xlf application/x-xpinstall xpi application/x-xz xz application/x-zmachine z1 z2 z3 z4 z5 z6 z7 z8 -# application/x400-bp -# application/xacml+xml application/xaml+xml xaml -# application/xcap-att+xml -# application/xcap-caps+xml application/xcap-diff+xml xdf -# application/xcap-el+xml -# application/xcap-error+xml -# application/xcap-ns+xml -# application/xcon-conference-info+xml -# application/xcon-conference-info-diff+xml application/xenc+xml xenc application/xhtml+xml xhtml xht -# application/xhtml-voice+xml application/xml xml xsl application/xml-dtd dtd -# application/xml-external-parsed-entity -# application/xml-patch+xml -# application/xmpp+xml application/xop+xml xop application/xproc+xml xpl application/xslt+xml xslt @@ -1365,156 +601,25 @@ application/xv+xml mxml xhvml xvml xvm application/yang yang application/yin+xml yin application/zip zip -# application/zlib -# audio/1d-interleaved-parityfec -# audio/32kadpcm -# audio/3gpp -# audio/3gpp2 -# audio/ac3 audio/adpcm adp -# audio/amr -# audio/amr-wb -# audio/amr-wb+ -# audio/aptx -# audio/asc -# audio/atrac-advanced-lossless -# audio/atrac-x -# audio/atrac3 audio/basic au snd -# audio/bv16 -# audio/bv32 -# audio/clearmode -# audio/cn -# audio/dat12 -# audio/dls -# audio/dsr-es201108 -# audio/dsr-es202050 -# audio/dsr-es202211 -# audio/dsr-es202212 -# audio/dv -# audio/dvi4 -# audio/eac3 -# audio/encaprtp -# audio/evrc -# audio/evrc-qcp -# audio/evrc0 -# audio/evrc1 -# audio/evrcb -# audio/evrcb0 -# audio/evrcb1 -# audio/evrcnw -# audio/evrcnw0 -# audio/evrcnw1 -# audio/evrcwb -# audio/evrcwb0 -# audio/evrcwb1 -# audio/evs -# audio/example -# audio/fwdred -# audio/g711-0 -# audio/g719 -# audio/g722 -# audio/g7221 -# audio/g723 -# audio/g726-16 -# audio/g726-24 -# audio/g726-32 -# audio/g726-40 -# audio/g728 -# audio/g729 -# audio/g7291 -# audio/g729d -# audio/g729e -# audio/gsm -# audio/gsm-efr -# audio/gsm-hr-08 -# audio/ilbc -# audio/ip-mr_v2.5 -# audio/isac -# audio/l16 -# audio/l20 -# audio/l24 -# audio/l8 -# audio/lpc audio/midi mid midi kar rmi -# audio/mobile-xmf audio/mp4 m4a mp4a -# audio/mp4a-latm -# audio/mpa -# audio/mpa-robust audio/mpeg mpga mp2 mp2a mp3 m2a m3a -# audio/mpeg4-generic -# audio/musepack -audio/ogg oga ogg spx -# audio/opus -# audio/parityfec -# audio/pcma -# audio/pcma-wb -# audio/pcmu -# audio/pcmu-wb -# audio/prs.sid -# audio/qcelp -# audio/raptorfec -# audio/red -# audio/rtp-enc-aescm128 -# audio/rtp-midi -# audio/rtploopback -# audio/rtx +audio/ogg oga ogg spx opus audio/s3m s3m audio/silk sil -# audio/smv -# audio/smv-qcp -# audio/smv0 -# audio/sp-midi -# audio/speex -# audio/t140c -# audio/t38 -# audio/telephone-event -# audio/tone -# audio/uemclip -# audio/ulpfec -# audio/vdvi -# audio/vmr-wb -# audio/vnd.3gpp.iufp -# audio/vnd.4sb -# audio/vnd.audiokoz -# audio/vnd.celp -# audio/vnd.cisco.nse -# audio/vnd.cmles.radio-events -# audio/vnd.cns.anp1 -# audio/vnd.cns.inf1 audio/vnd.dece.audio uva uvva audio/vnd.digital-winds eol -# audio/vnd.dlna.adts -# audio/vnd.dolby.heaac.1 -# audio/vnd.dolby.heaac.2 -# audio/vnd.dolby.mlp -# audio/vnd.dolby.mps -# audio/vnd.dolby.pl2 -# audio/vnd.dolby.pl2x -# audio/vnd.dolby.pl2z -# audio/vnd.dolby.pulse.1 audio/vnd.dra dra audio/vnd.dts dts audio/vnd.dts.hd dtshd -# audio/vnd.dvb.file -# audio/vnd.everad.plj -# audio/vnd.hns.audio audio/vnd.lucent.voice lvp audio/vnd.ms-playready.media.pya pya -# audio/vnd.nokia.mobile-xmf -# audio/vnd.nortel.vbk audio/vnd.nuera.ecelp4800 ecelp4800 audio/vnd.nuera.ecelp7470 ecelp7470 audio/vnd.nuera.ecelp9600 ecelp9600 -# audio/vnd.octel.sbc -# audio/vnd.qcelp -# audio/vnd.rhetorex.32kadpcm audio/vnd.rip rip -# audio/vnd.sealedmedia.softseal.mpeg -# audio/vnd.vmx.cvsd -# audio/vorbis -# audio/vorbis-config audio/webm weba audio/x-aac aac audio/x-aiff aif aiff aifc @@ -1526,7 +631,6 @@ audio/x-ms-wax wax audio/x-ms-wma wma audio/x-pn-realaudio ram ra audio/x-pn-realaudio-plugin rmp -# audio/x-tta audio/x-wav wav audio/xm xm chemical/x-cdx cdx @@ -1534,36 +638,26 @@ chemical/x-cif cif chemical/x-cmdf cmdf chemical/x-cml cml chemical/x-csml csml -# chemical/x-pdb chemical/x-xyz xyz +font/collection ttc +font/otf otf +font/ttf ttf +font/woff woff +font/woff2 woff2 +image/avif avif image/bmp bmp image/cgm cgm -# image/dicom-rle -# image/emf -# image/example -# image/fits image/g3fax g3 image/gif gif image/ief ief -# image/jls -# image/jp2 image/jpeg jpeg jpg jpe -# image/jpm -# image/jpx image/ktx ktx -# image/naplps image/png png image/prs.btif btif -# image/prs.pti -# image/pwg-raster image/sgi sgi image/svg+xml svg svgz -# image/t38 image/tiff tiff tif -# image/tiff-fx image/vnd.adobe.photoshop psd -# image/vnd.airzip.accelerator.azv -# image/vnd.cns.inf2 image/vnd.dece.graphic uvi uvvi uvg uvvg image/vnd.djvu djvu djv image/vnd.dvb.subtitle sub @@ -1574,25 +668,12 @@ image/vnd.fpx fpx image/vnd.fst fst image/vnd.fujixerox.edmics-mmr mmr image/vnd.fujixerox.edmics-rlc rlc -# image/vnd.globalgraphics.pgb -# image/vnd.microsoft.icon -# image/vnd.mix -# image/vnd.mozilla.apng image/vnd.ms-modi mdi image/vnd.ms-photo wdp image/vnd.net-fpx npx -# image/vnd.radiance -# image/vnd.sealed.png -# image/vnd.sealedmedia.softseal.gif -# image/vnd.sealedmedia.softseal.jpg -# image/vnd.svf -# image/vnd.tencent.tap -# image/vnd.valve.source.texture image/vnd.wap.wbmp wbmp image/vnd.xiff xif -# image/vnd.zbrush.pcx image/webp webp -# image/wmf image/x-3ds 3ds image/x-cmu-raster ras image/x-cmx cmx @@ -1610,137 +691,45 @@ image/x-tga tga image/x-xbitmap xbm image/x-xpixmap xpm image/x-xwindowdump xwd -# message/cpim -# message/delivery-status -# message/disposition-notification -# message/example -# message/external-body -# message/feedback-report -# message/global -# message/global-delivery-status -# message/global-disposition-notification -# message/global-headers -# message/http -# message/imdn+xml -# message/news -# message/partial message/rfc822 eml mime -# message/s-http -# message/sip -# message/sipfrag -# message/tracking-status -# message/vnd.si.simp -# message/vnd.wfa.wsc -# model/example -# model/gltf+json model/iges igs iges model/mesh msh mesh silo model/vnd.collada+xml dae model/vnd.dwf dwf -# model/vnd.flatland.3dml model/vnd.gdl gdl -# model/vnd.gs-gdl -# model/vnd.gs.gdl model/vnd.gtw gtw -# model/vnd.moml+xml -model/vnd.mts mts -# model/vnd.opengex -# model/vnd.parasolid.transmit.binary -# model/vnd.parasolid.transmit.text -# model/vnd.rosette.annotated-data-model -# model/vnd.valve.source.compiled-map model/vnd.vtu vtu model/vrml wrl vrml model/x3d+binary x3db x3dbz -# model/x3d+fastinfoset model/x3d+vrml x3dv x3dvz model/x3d+xml x3d x3dz -# model/x3d-vrml -# multipart/alternative -# multipart/appledouble -# multipart/byteranges -# multipart/digest -# multipart/encrypted -# multipart/example -# multipart/form-data -# multipart/header-set -# multipart/mixed -# multipart/parallel -# multipart/related -# multipart/report -# multipart/signed -# multipart/voice-message -# multipart/x-mixed-replace -# text/1d-interleaved-parityfec text/cache-manifest appcache text/calendar ics ifb text/css css text/csv csv -# text/csv-schema -# text/directory -# text/dns -# text/ecmascript -# text/encaprtp -# text/enriched -# text/example -# text/fwdred -# text/grammar-ref-list text/html html htm -# text/javascript -# text/jcr-cnd -# text/markdown -# text/mizar +text/javascript js mjs text/n3 n3 -# text/parameters -# text/parityfec text/plain txt text conf def list log in -# text/provenance-notation -# text/prs.fallenstein.rst text/prs.lines.tag dsc -# text/prs.prop.logic -# text/raptorfec -# text/red -# text/rfc822-headers text/richtext rtx -# text/rtf -# text/rtp-enc-aescm128 -# text/rtploopback -# text/rtx text/sgml sgml sgm -# text/t140 text/tab-separated-values tsv text/troff t tr roff man me ms text/turtle ttl -# text/ulpfec text/uri-list uri uris urls text/vcard vcard -# text/vnd.a -# text/vnd.abc text/vnd.curl curl text/vnd.curl.dcurl dcurl text/vnd.curl.mcurl mcurl text/vnd.curl.scurl scurl -# text/vnd.debian.copyright -# text/vnd.dmclientscript text/vnd.dvb.subtitle sub -# text/vnd.esmertec.theme-descriptor text/vnd.fly fly text/vnd.fmi.flexstor flx text/vnd.graphviz gv text/vnd.in3d.3dml 3dml text/vnd.in3d.spot spot -# text/vnd.iptc.newsml -# text/vnd.iptc.nitf -# text/vnd.latex-z -# text/vnd.motorola.reflex -# text/vnd.ms-mediapackage -# text/vnd.net2phone.commcenter.command -# text/vnd.radisys.msml-basic-layout -# text/vnd.si.uricatalogue text/vnd.sun.j2me.app-descriptor jad -# text/vnd.trolltech.linguist -# text/vnd.wap.si -# text/vnd.wap.sl text/vnd.wap.wml wml text/vnd.wap.wmlscript wmls text/x-asm s asm @@ -1755,87 +744,30 @@ text/x-sfv sfv text/x-uuencode uu text/x-vcalendar vcs text/x-vcard vcf -# text/xml -# text/xml-external-parsed-entity -# video/1d-interleaved-parityfec video/3gpp 3gp -# video/3gpp-tt video/3gpp2 3g2 -# video/bmpeg -# video/bt656 -# video/celb -# video/dv -# video/encaprtp -# video/example video/h261 h261 video/h263 h263 -# video/h263-1998 -# video/h263-2000 video/h264 h264 -# video/h264-rcdo -# video/h264-svc -# video/h265 -# video/iso.segment video/jpeg jpgv -# video/jpeg2000 video/jpm jpm jpgm video/mj2 mj2 mjp2 -# video/mp1s -# video/mp2p -# video/mp2t +video/mp2t ts m2t m2ts mts video/mp4 mp4 mp4v mpg4 -# video/mp4v-es video/mpeg mpeg mpg mpe m1v m2v -# video/mpeg4-generic -# video/mpv -# video/nv video/ogg ogv -# video/parityfec -# video/pointer video/quicktime qt mov -# video/raptorfec -# video/raw -# video/rtp-enc-aescm128 -# video/rtploopback -# video/rtx -# video/smpte292m -# video/ulpfec -# video/vc1 -# video/vnd.cctv video/vnd.dece.hd uvh uvvh video/vnd.dece.mobile uvm uvvm -# video/vnd.dece.mp4 video/vnd.dece.pd uvp uvvp video/vnd.dece.sd uvs uvvs video/vnd.dece.video uvv uvvv -# video/vnd.directv.mpeg -# video/vnd.directv.mpeg-tts -# video/vnd.dlna.mpeg-tts video/vnd.dvb.file dvb video/vnd.fvt fvt -# video/vnd.hns.video -# video/vnd.iptvforum.1dparityfec-1010 -# video/vnd.iptvforum.1dparityfec-2005 -# video/vnd.iptvforum.2dparityfec-1010 -# video/vnd.iptvforum.2dparityfec-2005 -# video/vnd.iptvforum.ttsavc -# video/vnd.iptvforum.ttsmpeg2 -# video/vnd.motorola.video -# video/vnd.motorola.videop video/vnd.mpegurl mxu m4u video/vnd.ms-playready.media.pyv pyv -# video/vnd.nokia.interleaved-multimedia -# video/vnd.nokia.videovoip -# video/vnd.objectvideo -# video/vnd.radgamettools.bink -# video/vnd.radgamettools.smacker -# video/vnd.sealed.mpeg1 -# video/vnd.sealed.mpeg4 -# video/vnd.sealed.swf -# video/vnd.sealedmedia.softseal.mov video/vnd.uvvu.mp4 uvu uvvu video/vnd.vivo viv -# video/vp8 video/webm webm video/x-f4v f4v video/x-fli fli diff --git a/spring-web/src/test/java/org/springframework/http/ContentDispositionTests.java b/spring-web/src/test/java/org/springframework/http/ContentDispositionTests.java index 50612a84d4db..0c623407bae9 100644 --- a/spring-web/src/test/java/org/springframework/http/ContentDispositionTests.java +++ b/spring-web/src/test/java/org/springframework/http/ContentDispositionTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -223,6 +223,14 @@ void parseIgnoresInvalidDates() { .build()); } + @Test + void parseAttributesCaseInsensitively() { + ContentDisposition cd = ContentDisposition.parse("form-data; Name=\"foo\"; FileName=\"bar.txt\""); + assertThat(cd.getName()).isEqualTo("foo"); + assertThat(cd.getFilename()).isEqualTo("bar.txt"); + assertThat(cd.toString()).isEqualTo("form-data; name=\"foo\"; filename=\"bar.txt\""); + } + @Test void parseEmpty() { assertThatIllegalArgumentException().isThrownBy(() -> parse("")); 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 7e0736b8f0db..c6a75829107f 100644 --- a/spring-web/src/test/java/org/springframework/http/HttpEntityTests.java +++ b/spring-web/src/test/java/org/springframework/http/HttpEntityTests.java @@ -75,8 +75,8 @@ void testEquals() { 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<>(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(); diff --git a/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java b/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java index 5725b0b4324c..7abbd6c633ca 100644 --- a/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java +++ b/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java @@ -35,6 +35,7 @@ import java.util.Set; import java.util.TimeZone; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import static java.util.stream.Collectors.toList; @@ -54,14 +55,25 @@ */ class HttpHeadersTests { - private final HttpHeaders headers = new HttpHeaders(); + final HttpHeaders headers = new HttpHeaders(); + @Test + void constructorUnwrapsReadonly() { + headers.setContentType(MediaType.APPLICATION_JSON); + HttpHeaders readOnly = HttpHeaders.readOnlyHttpHeaders(headers); + assertThat(readOnly.getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + HttpHeaders writable = new HttpHeaders(readOnly); + writable.setContentType(MediaType.TEXT_PLAIN); + // content-type value is cached by ReadOnlyHttpHeaders + assertThat(readOnly.getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + assertThat(writable.getContentType()).isEqualTo(MediaType.TEXT_PLAIN); + } @Test void writableHttpHeadersUnwrapsMultiple() { HttpHeaders originalExchangeHeaders = HttpHeaders.readOnlyHttpHeaders(new HttpHeaders()); HttpHeaders firewallHeaders = new HttpHeaders(originalExchangeHeaders); - HttpHeaders writeable = HttpHeaders.writableHttpHeaders(firewallHeaders); + HttpHeaders writeable = new HttpHeaders(firewallHeaders); writeable.setContentType(MediaType.APPLICATION_JSON); } @@ -150,6 +162,17 @@ void contentLength() { assertThat(headers.getFirst("Content-Length")).as("Invalid Content-Length header").isEqualTo("42"); } + @Test + void setContentLengthWithNegativeValue() { + assertThatIllegalArgumentException().isThrownBy(() -> + headers.setContentLength(-1)); + } + + @Test + void getContentLengthReturnsMinusOneForAbsentHeader() { + assertThat(headers.getContentLength()).isEqualTo(-1); + } + @Test void contentType() { MediaType contentType = new MediaType("text", "html", StandardCharsets.UTF_8); @@ -198,16 +221,25 @@ void ipv6Host() { assertThat(headers.getFirst("Host")).as("Invalid Host header").isEqualTo("[::1]"); } + @Test // gh-33716 + void hostDeletion() { + InetSocketAddress host = InetSocketAddress.createUnresolved("localhost", 8080); + headers.setHost(host); + headers.setHost(null); + assertThat(headers.getHost()).as("Host is not deleted").isEqualTo(null); + assertThat(headers.getFirst("Host")).as("Host is not deleted").isEqualTo(null); + } + @Test - void illegalETagWithoutQuotes() { - String eTag = "v2.6"; - assertThatIllegalArgumentException().isThrownBy(() -> headers.setETag(eTag)); + void eTagWithoutQuotes() { + headers.setETag("v2.6"); + assertThat(headers.getETag()).isEqualTo("\"v2.6\""); } @Test - void illegalWeakETagWithoutLeadingQuote() { - String etag = "W/v2.6\""; - assertThatIllegalArgumentException().isThrownBy(() -> headers.setETag(etag)); + void weakETagWithoutLeadingQuote() { + headers.setETag("W/v2.6\""); + assertThat(headers.getETag()).isEqualTo("\"W/v2.6\"\""); } @Test @@ -508,6 +540,20 @@ void acceptLanguage() { assertThat(headers.getAcceptLanguageAsLocales()).first().isEqualTo(Locale.FRANCE); } + @Test // gh-32259 + void acceptLanguageTrailingSemicolon() { + String headerValue = "en-us,en;,nl;"; + headers.set(HttpHeaders.ACCEPT_LANGUAGE, headerValue); + assertThat(headers.getFirst(HttpHeaders.ACCEPT_LANGUAGE)).isEqualTo(headerValue); + + List expectedRanges = Arrays.asList( + new Locale.LanguageRange("en-us"), + new Locale.LanguageRange("en"), + new Locale.LanguageRange("nl") + ); + assertThat(headers.getAcceptLanguage()).isEqualTo(expectedRanges); + } + @Test // SPR-15603 void acceptLanguageWithEmptyValue() { this.headers.set(HttpHeaders.ACCEPT_LANGUAGE, ""); @@ -592,182 +638,188 @@ void bearerAuth() { assertThat(authorization).isEqualTo("Bearer foo"); } - @Test - void keySetOperations() { - headers.add("Alpha", "apple"); - headers.add("Bravo", "banana"); - Set keySet = headers.keySet(); - - // Please DO NOT simplify the following with AssertJ's fluent API. - // - // We explicitly invoke methods directly on HttpHeaders#keySet() - // here to check the behavior of the entire contract. - - // isEmpty() and size() - assertThat(keySet).isNotEmpty(); - assertThat(keySet).hasSize(2); - - // contains() - assertThat(keySet.contains("Alpha")).as("Alpha should be present").isTrue(); - assertThat(keySet.contains("alpha")).as("alpha should be present").isTrue(); - assertThat(keySet.contains("Bravo")).as("Bravo should be present").isTrue(); - assertThat(keySet.contains("BRAVO")).as("BRAVO should be present").isTrue(); - assertThat(keySet.contains("Charlie")).as("Charlie should not be present").isFalse(); - - // toArray() - assertThat(keySet.toArray()).isEqualTo(new String[] {"Alpha", "Bravo"}); - - // spliterator() via stream() - assertThat(keySet.stream().collect(toList())).isEqualTo(Arrays.asList("Alpha", "Bravo")); - - // iterator() - List results = new ArrayList<>(); - keySet.iterator().forEachRemaining(results::add); - assertThat(results).isEqualTo(Arrays.asList("Alpha", "Bravo")); - - // remove() - assertThat(keySet.remove("Alpha")).isTrue(); - assertThat(keySet).hasSize(1); - assertThat(headers).hasSize(1); - assertThat(keySet.remove("Alpha")).isFalse(); - assertThat(keySet).hasSize(1); - assertThat(headers).hasSize(1); - - // clear() - keySet.clear(); - assertThat(keySet).isEmpty(); - assertThat(keySet).isEmpty(); - assertThat(headers).isEmpty(); - assertThat(headers).isEmpty(); - - // Unsupported operations - assertThatExceptionOfType(UnsupportedOperationException.class) - .isThrownBy(() -> keySet.add("x")); - assertThatExceptionOfType(UnsupportedOperationException.class) - .isThrownBy(() -> keySet.addAll(Collections.singleton("enigma"))); - } - - /** - * This method intentionally checks a wider/different range of functionality - * than {@link #removalFromKeySetRemovesEntryFromUnderlyingMap()}. - */ - @Test // https://github.com/spring-projects/spring-framework/issues/23633 - void keySetRemovalChecks() { - // --- Given --- - headers.add("Alpha", "apple"); - headers.add("Bravo", "banana"); - assertThat(headers).containsOnlyKeys("Alpha", "Bravo"); - - // --- When --- - boolean removed = headers.keySet().remove("Alpha"); - - // --- Then --- - - // Please DO NOT simplify the following with AssertJ's fluent API. - // - // We explicitly invoke methods directly on HttpHeaders here to check - // the behavior of the entire contract. - - assertThat(removed).isTrue(); - assertThat(headers.keySet().remove("Alpha")).isFalse(); - assertThat(headers).hasSize(1); - assertThat(headers.containsKey("Alpha")).as("Alpha should have been removed").isFalse(); - assertThat(headers.containsKey("Bravo")).as("Bravo should be present").isTrue(); - assertThat(headers.keySet()).containsOnly("Bravo"); - assertThat(headers.entrySet()).containsOnly(entry("Bravo", List.of("banana"))); - } - @Test - void removalFromKeySetRemovesEntryFromUnderlyingMap() { - String headerName = "MyHeader"; - String headerValue = "value"; + @Nested + class MapEntriesTests { + + @Test + void keySetOperations() { + headers.add("Alpha", "apple"); + headers.add("Bravo", "banana"); + Set keySet = headers.keySet(); + + // Please DO NOT simplify the following with AssertJ's fluent API. + // + // We explicitly invoke methods directly on HttpHeaders#keySet() + // here to check the behavior of the entire contract. + + // isEmpty() and size() + assertThat(keySet).isNotEmpty(); + assertThat(keySet).hasSize(2); + + // contains() + assertThat(keySet.contains("Alpha")).as("Alpha should be present").isTrue(); + assertThat(keySet.contains("alpha")).as("alpha should be present").isTrue(); + assertThat(keySet.contains("Bravo")).as("Bravo should be present").isTrue(); + assertThat(keySet.contains("BRAVO")).as("BRAVO should be present").isTrue(); + assertThat(keySet.contains("Charlie")).as("Charlie should not be present").isFalse(); + + // toArray() + assertThat(keySet.toArray()).isEqualTo(new String[] {"Alpha", "Bravo"}); + + // spliterator() via stream() + assertThat(keySet.stream().collect(toList())).isEqualTo(Arrays.asList("Alpha", "Bravo")); + + // iterator() + List results = new ArrayList<>(); + keySet.iterator().forEachRemaining(results::add); + assertThat(results).isEqualTo(Arrays.asList("Alpha", "Bravo")); + + // remove() + assertThat(keySet.remove("Alpha")).isTrue(); + assertThat(keySet).hasSize(1); + assertThat(headers).hasSize(1); + assertThat(keySet.remove("Alpha")).isFalse(); + assertThat(keySet).hasSize(1); + assertThat(headers).hasSize(1); + + // clear() + keySet.clear(); + assertThat(keySet).isEmpty(); + assertThat(keySet).isEmpty(); + assertThat(headers).isEmpty(); + assertThat(headers).isEmpty(); + + // Unsupported operations + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> keySet.add("x")); + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> keySet.addAll(Collections.singleton("enigma"))); + } - assertThat(headers).isEmpty(); - headers.add(headerName, headerValue); - assertThat(headers.containsKey(headerName)).isTrue(); - headers.keySet().removeIf(key -> key.equals(headerName)); - assertThat(headers).isEmpty(); - headers.add(headerName, headerValue); - assertThat(headers.get(headerName)).containsExactly(headerValue); - } + /** + * This method intentionally checks a wider/different range of functionality + * than {@link #removalFromKeySetRemovesEntryFromUnderlyingMap()}. + */ + @Test // https://github.com/spring-projects/spring-framework/issues/23633 + void keySetRemovalChecks() { + // --- Given --- + headers.add("Alpha", "apple"); + headers.add("Bravo", "banana"); + assertThat(headers).containsOnlyKeys("Alpha", "Bravo"); + + // --- When --- + boolean removed = headers.keySet().remove("Alpha"); + + // --- Then --- + + // Please DO NOT simplify the following with AssertJ's fluent API. + // + // We explicitly invoke methods directly on HttpHeaders here to check + // the behavior of the entire contract. + + assertThat(removed).isTrue(); + assertThat(headers.keySet().remove("Alpha")).isFalse(); + assertThat(headers).hasSize(1); + assertThat(headers.containsKey("Alpha")).as("Alpha should have been removed").isFalse(); + assertThat(headers.containsKey("Bravo")).as("Bravo should be present").isTrue(); + assertThat(headers.keySet()).containsOnly("Bravo"); + assertThat(headers.entrySet()).containsOnly(entry("Bravo", List.of("banana"))); + } - @Test - void removalFromEntrySetRemovesEntryFromUnderlyingMap() { - String headerName = "MyHeader"; - String headerValue = "value"; + @Test + void removalFromKeySetRemovesEntryFromUnderlyingMap() { + String headerName = "MyHeader"; + String headerValue = "value"; + + assertThat(headers).isEmpty(); + headers.add(headerName, headerValue); + assertThat(headers.containsKey(headerName)).isTrue(); + headers.keySet().removeIf(key -> key.equals(headerName)); + assertThat(headers).isEmpty(); + headers.add(headerName, headerValue); + assertThat(headers.get(headerName)).containsExactly(headerValue); + } - assertThat(headers).isEmpty(); - headers.add(headerName, headerValue); - assertThat(headers.containsKey(headerName)).isTrue(); - headers.entrySet().removeIf(entry -> entry.getKey().equals(headerName)); - assertThat(headers).isEmpty(); - headers.add(headerName, headerValue); - assertThat(headers.get(headerName)).containsExactly(headerValue); - } + @Test + void removalFromEntrySetRemovesEntryFromUnderlyingMap() { + String headerName = "MyHeader"; + String headerValue = "value"; + + assertThat(headers).isEmpty(); + headers.add(headerName, headerValue); + assertThat(headers.containsKey(headerName)).isTrue(); + headers.entrySet().removeIf(entry -> entry.getKey().equals(headerName)); + assertThat(headers).isEmpty(); + headers.add(headerName, headerValue); + assertThat(headers.get(headerName)).containsExactly(headerValue); + } - @Test - void readOnlyHttpHeadersRetainEntrySetOrder() { - headers.add("aardvark", "enigma"); - headers.add("beaver", "enigma"); - headers.add("cat", "enigma"); - headers.add("dog", "enigma"); - headers.add("elephant", "enigma"); + @Test + void readOnlyHttpHeadersRetainEntrySetOrder() { + headers.add("aardvark", "enigma"); + headers.add("beaver", "enigma"); + headers.add("cat", "enigma"); + headers.add("dog", "enigma"); + headers.add("elephant", "enigma"); - String[] expectedKeys = new String[] { "aardvark", "beaver", "cat", "dog", "elephant" }; + String[] expectedKeys = new String[] { "aardvark", "beaver", "cat", "dog", "elephant" }; - assertThat(headers.entrySet()).extracting(Entry::getKey).containsExactly(expectedKeys); + assertThat(headers.entrySet()).extracting(Entry::getKey).containsExactly(expectedKeys); - HttpHeaders readOnlyHttpHeaders = HttpHeaders.readOnlyHttpHeaders(headers); - assertThat(readOnlyHttpHeaders.entrySet()).extracting(Entry::getKey).containsExactly(expectedKeys); - } + HttpHeaders readOnlyHttpHeaders = HttpHeaders.readOnlyHttpHeaders(headers); + assertThat(readOnlyHttpHeaders.entrySet()).extracting(Entry::getKey).containsExactly(expectedKeys); + } - @Test - void readOnlyHttpHeadersCopyOrderTest() { - headers.add("aardvark", "enigma"); - headers.add("beaver", "enigma"); - headers.add("cat", "enigma"); - headers.add("dog", "enigma"); - headers.add("elephant", "enigma"); + @Test + void readOnlyHttpHeadersCopyOrderTest() { + headers.add("aardvark", "enigma"); + headers.add("beaver", "enigma"); + headers.add("cat", "enigma"); + headers.add("dog", "enigma"); + headers.add("elephant", "enigma"); - String[] expectedKeys = new String[] { "aardvark", "beaver", "cat", "dog", "elephant" }; + String[] expectedKeys = new String[] { "aardvark", "beaver", "cat", "dog", "elephant" }; - HttpHeaders readOnlyHttpHeaders = HttpHeaders.readOnlyHttpHeaders(headers); + HttpHeaders readOnlyHttpHeaders = HttpHeaders.readOnlyHttpHeaders(headers); - HttpHeaders forEachHeaders = new HttpHeaders(); - readOnlyHttpHeaders.forEach(forEachHeaders::putIfAbsent); - assertThat(forEachHeaders.entrySet()).extracting(Entry::getKey).containsExactly(expectedKeys); + HttpHeaders forEachHeaders = new HttpHeaders(); + readOnlyHttpHeaders.forEach(forEachHeaders::putIfAbsent); + assertThat(forEachHeaders.entrySet()).extracting(Entry::getKey).containsExactly(expectedKeys); - HttpHeaders putAllHeaders = new HttpHeaders(); - putAllHeaders.putAll(readOnlyHttpHeaders); - assertThat(putAllHeaders.entrySet()).extracting(Entry::getKey).containsExactly(expectedKeys); + HttpHeaders putAllHeaders = new HttpHeaders(); + putAllHeaders.putAll(readOnlyHttpHeaders); + assertThat(putAllHeaders.entrySet()).extracting(Entry::getKey).containsExactly(expectedKeys); - HttpHeaders addAllHeaders = new HttpHeaders(); - addAllHeaders.addAll(readOnlyHttpHeaders); - assertThat(addAllHeaders.entrySet()).extracting(Entry::getKey).containsExactly(expectedKeys); - } + HttpHeaders addAllHeaders = new HttpHeaders(); + addAllHeaders.addAll(readOnlyHttpHeaders); + assertThat(addAllHeaders.entrySet()).extracting(Entry::getKey).containsExactly(expectedKeys); + } - @Test // gh-25034 - void equalsUnwrapsHttpHeaders() { - HttpHeaders headers1 = new HttpHeaders(); - HttpHeaders headers2 = new HttpHeaders(new HttpHeaders(headers1)); + @Test // gh-25034 + void equalsUnwrapsHttpHeaders() { + HttpHeaders headers1 = new HttpHeaders(); + HttpHeaders headers2 = new HttpHeaders(new HttpHeaders(headers1)); - assertThat(headers1).isEqualTo(headers2); - assertThat(headers2).isEqualTo(headers1); - } + assertThat(headers1).isEqualTo(headers2); + assertThat(headers2).isEqualTo(headers1); + } - @Test - void getValuesAsList() { - HttpHeaders headers = new HttpHeaders(); - headers.add("Foo", "Bar"); - headers.add("Foo", "Baz, Qux"); - headers.add("Quux", "\t\"Corge\", \"Grault\""); - headers.add("Garply", " Waldo \"Fred\\!\", \"\tPlugh, Xyzzy! \""); - headers.add("Example-Dates", "\"Sat, 04 May 1996\", \"Wed, 14 Sep 2005\""); + @Test + void getValuesAsList() { + HttpHeaders headers = new HttpHeaders(); + headers.add("Foo", "Bar"); + headers.add("Foo", "Baz, Qux"); + headers.add("Quux", "\t\"Corge\", \"Grault\""); + headers.add("Garply", " Waldo \"Fred\\!\", \"\tPlugh, Xyzzy! \""); + headers.add("Example-Dates", "\"Sat, 04 May 1996\", \"Wed, 14 Sep 2005\""); + + assertThat(headers.getValuesAsList("Foo")).containsExactly("Bar", "Baz", "Qux"); + assertThat(headers.getValuesAsList("Quux")).containsExactly("Corge", "Grault"); + assertThat(headers.getValuesAsList("Garply")).containsExactly("Waldo \"Fred\\!\"", "\tPlugh, Xyzzy! "); + assertThat(headers.getValuesAsList("Example-Dates")).containsExactly("Sat, 04 May 1996", "Wed, 14 Sep 2005"); + } - assertThat(headers.getValuesAsList("Foo")).containsExactly("Bar", "Baz", "Qux"); - assertThat(headers.getValuesAsList("Quux")).containsExactly("Corge", "Grault"); - assertThat(headers.getValuesAsList("Garply")).containsExactly("Waldo \"Fred\\!\"", "\tPlugh, Xyzzy! "); - assertThat(headers.getValuesAsList("Example-Dates")).containsExactly("Sat, 04 May 1996", "Wed, 14 Sep 2005"); } } diff --git a/spring-web/src/test/java/org/springframework/http/MediaTypeFactoryTests.java b/spring-web/src/test/java/org/springframework/http/MediaTypeFactoryTests.java index ce825c3af4ca..28bdc6f00e6c 100644 --- a/spring-web/src/test/java/org/springframework/http/MediaTypeFactoryTests.java +++ b/spring-web/src/test/java/org/springframework/http/MediaTypeFactoryTests.java @@ -24,14 +24,16 @@ /** * @author Arjen Poutsma + * @author Sebastien Deleuze */ class MediaTypeFactoryTests { @Test void getMediaType() { assertThat(MediaTypeFactory.getMediaType("file.xml")).contains(MediaType.APPLICATION_XML); - assertThat(MediaTypeFactory.getMediaType("file.js")).contains(MediaType.parseMediaType("application/javascript")); + assertThat(MediaTypeFactory.getMediaType("file.js")).contains(MediaType.parseMediaType("text/javascript")); assertThat(MediaTypeFactory.getMediaType("file.css")).contains(MediaType.parseMediaType("text/css")); + assertThat(MediaTypeFactory.getMediaType("file.wasm")).contains(MediaType.parseMediaType("application/wasm")); assertThat(MediaTypeFactory.getMediaType("file.foobar")).isNotPresent(); } diff --git a/spring-web/src/test/java/org/springframework/http/ResponseCookieTests.java b/spring-web/src/test/java/org/springframework/http/ResponseCookieTests.java index 444b0dd26e2b..e1e19dc6b4bd 100644 --- a/spring-web/src/test/java/org/springframework/http/ResponseCookieTests.java +++ b/spring-web/src/test/java/org/springframework/http/ResponseCookieTests.java @@ -37,12 +37,12 @@ void basic() { assertThat(ResponseCookie.from("id", "1fWa").build().toString()).isEqualTo("id=1fWa"); ResponseCookie cookie = ResponseCookie.from("id", "1fWa") - .domain("abc").path("/path").maxAge(0).httpOnly(true).secure(true).sameSite("None") + .domain("abc").path("/path").maxAge(0).httpOnly(true).partitioned(true).secure(true).sameSite("None") .build(); assertThat(cookie.toString()).isEqualTo("id=1fWa; Path=/path; Domain=abc; " + "Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; " + - "Secure; HttpOnly; SameSite=None"); + "Secure; HttpOnly; Partitioned; SameSite=None"); } @Test diff --git a/spring-web/src/test/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactoryTests.java b/spring-web/src/test/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactoryTests.java index 8ba0fac6c4c5..76acd3052d49 100644 --- a/spring-web/src/test/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactoryTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/HttpComponentsClientHttpRequestFactoryTests.java @@ -18,6 +18,7 @@ import java.io.InputStreamReader; import java.net.URI; +import java.time.Duration; import java.util.stream.Stream; import org.apache.hc.client5.http.classic.HttpClient; @@ -66,6 +67,7 @@ void assertCustomConfig() throws Exception { HttpComponentsClientHttpRequestFactory hrf = new HttpComponentsClientHttpRequestFactory(httpClient); hrf.setConnectTimeout(1234); hrf.setConnectionRequestTimeout(4321); + hrf.setReadTimeout(5678); URI uri = URI.create(baseUrl + "/status/ok"); HttpComponentsClientHttpRequest request = (HttpComponentsClientHttpRequest) hrf.createRequest(uri, HttpMethod.GET); @@ -76,6 +78,7 @@ void assertCustomConfig() throws Exception { RequestConfig requestConfig = (RequestConfig) config; assertThat(requestConfig.getConnectTimeout()).as("Wrong custom connection timeout").isEqualTo(Timeout.of(1234, MILLISECONDS)); assertThat(requestConfig.getConnectionRequestTimeout()).as("Wrong custom connection request timeout").isEqualTo(Timeout.of(4321, MILLISECONDS)); + assertThat(requestConfig.getResponseTimeout()).as("Wrong custom response timeout").isEqualTo(Timeout.of(5678, MILLISECONDS)); } @Test @@ -105,6 +108,7 @@ void localSettingsOverrideClientDefaultSettings() throws Exception { RequestConfig defaultConfig = RequestConfig.custom() .setConnectTimeout(1234, MILLISECONDS) .setConnectionRequestTimeout(6789, MILLISECONDS) + .setResponseTimeout(4321, MILLISECONDS) .build(); CloseableHttpClient client = mock(CloseableHttpClient.class, withSettings().extraInterfaces(Configurable.class)); @@ -113,10 +117,12 @@ void localSettingsOverrideClientDefaultSettings() throws Exception { HttpComponentsClientHttpRequestFactory hrf = new HttpComponentsClientHttpRequestFactory(client); hrf.setConnectTimeout(5000); + hrf.setReadTimeout(Duration.ofMillis(4000)); RequestConfig requestConfig = retrieveRequestConfig(hrf); assertThat(requestConfig.getConnectTimeout()).isEqualTo(Timeout.of(5000, MILLISECONDS)); assertThat(requestConfig.getConnectionRequestTimeout()).isEqualTo(Timeout.of(6789, MILLISECONDS)); + assertThat(requestConfig.getResponseTimeout()).isEqualTo(Timeout.of(4000, MILLISECONDS)); } @Test @@ -141,15 +147,18 @@ public HttpClient getHttpClient() { RequestConfig requestConfig = retrieveRequestConfig(hrf); assertThat(requestConfig.getConnectionRequestTimeout()).isEqualTo(Timeout.of(5000, MILLISECONDS)); assertThat(requestConfig.getConnectTimeout()).isEqualTo(RequestConfig.DEFAULT.getConnectTimeout()); + assertThat(requestConfig.getResponseTimeout()).isEqualTo(RequestConfig.DEFAULT.getResponseTimeout()); // Update the Http client so that it returns an updated config RequestConfig updatedDefaultConfig = RequestConfig.custom() .setConnectTimeout(1234, MILLISECONDS).build(); given(configurable.getConfig()).willReturn(updatedDefaultConfig); hrf.setConnectionRequestTimeout(7000); + hrf.setReadTimeout(4000); RequestConfig requestConfig2 = retrieveRequestConfig(hrf); assertThat(requestConfig2.getConnectTimeout()).isEqualTo(Timeout.of(1234, MILLISECONDS)); assertThat(requestConfig2.getConnectionRequestTimeout()).isEqualTo(Timeout.of(7000, MILLISECONDS)); + assertThat(requestConfig2.getResponseTimeout()).isEqualTo(Timeout.of(4000, MILLISECONDS)); } @ParameterizedTest diff --git a/spring-web/src/test/java/org/springframework/http/client/InterceptingClientHttpRequestFactoryTests.java b/spring-web/src/test/java/org/springframework/http/client/InterceptingClientHttpRequestFactoryTests.java index cd08464d2076..f8248d0e0858 100644 --- a/spring-web/src/test/java/org/springframework/http/client/InterceptingClientHttpRequestFactoryTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/InterceptingClientHttpRequestFactoryTests.java @@ -115,6 +115,32 @@ protected ClientHttpResponse executeInternal() { request.execute(); } + @Test + void changeAttribute() throws Exception { + final String attrName = "Foo"; + final String attrValue = "Bar"; + + ClientHttpRequestInterceptor interceptor = (request, body, execution) -> { + System.out.println("interceptor"); + request.getAttributes().put(attrName, attrValue); + return execution.execute(request, body); + }; + + requestMock = new MockClientHttpRequest() { + @Override + protected ClientHttpResponse executeInternal() { + System.out.println("execute"); + assertThat(getAttributes()).containsEntry(attrName, attrValue); + return responseMock; + } + }; + + requestFactory = new InterceptingClientHttpRequestFactory(requestFactoryMock, Collections.singletonList(interceptor)); + + ClientHttpRequest request = requestFactory.createRequest(URI.create("https://example.com"), HttpMethod.GET); + request.execute(); + } + @Test void changeURI() throws Exception { final URI changedUri = URI.create("https://example.com/2"); @@ -178,6 +204,7 @@ void changeBody() throws Exception { ClientHttpRequest request = requestFactory.createRequest(URI.create("https://example.com"), HttpMethod.GET); request.execute(); assertThat(Arrays.equals(changedBody, requestMock.getBodyAsBytes())).isTrue(); + assertThat(requestMock.getHeaders().getContentLength()).isEqualTo(changedBody.length); } diff --git a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java index 0f2d407348ee..443cb069526a 100644 --- a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java @@ -31,6 +31,8 @@ import static org.assertj.core.api.Assertions.assertThat; /** + * Tests for {@link JdkClientHttpRequestFactory}. + * * @author Marten Deinum */ class JdkClientHttpRequestFactoryTests extends AbstractHttpRequestFactoryTests { @@ -68,12 +70,12 @@ void httpMethods() throws Exception { @Test void customizeDisallowedHeaders() throws IOException { - ClientHttpRequest request = this.factory.createRequest(URI.create(this.baseUrl + "/status/299"), HttpMethod.PUT); - request.getHeaders().set("Expect", "299"); + ClientHttpRequest request = this.factory.createRequest(URI.create(this.baseUrl + "/status/299"), HttpMethod.PUT); + request.getHeaders().set("Expect", "299"); - try (ClientHttpResponse response = request.execute()) { - assertThat(response.getStatusCode()).as("Invalid status code").isEqualTo(HttpStatusCode.valueOf(299)); - } + try (ClientHttpResponse response = request.execute()) { + assertThat(response.getStatusCode()).as("Invalid status code").isEqualTo(HttpStatusCode.valueOf(299)); + } } @Test // gh-31451 diff --git a/spring-web/src/test/java/org/springframework/http/client/OutputStreamPublisherTests.java b/spring-web/src/test/java/org/springframework/http/client/OutputStreamPublisherTests.java index 466e518e58a9..b33b1f693f47 100644 --- a/spring-web/src/test/java/org/springframework/http/client/OutputStreamPublisherTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/OutputStreamPublisherTests.java @@ -64,11 +64,11 @@ public byte[] map(byte[] b, int off, int len) { @Test void basic() { - Flow.Publisher flowPublisher = OutputStreamPublisher.create(outputStream -> { + Flow.Publisher flowPublisher = new OutputStreamPublisher<>(outputStream -> { outputStream.write(FOO); outputStream.write(BAR); outputStream.write(BAZ); - }, this.byteMapper, this.executor); + }, this.byteMapper, this.executor, null); Flux flux = toString(flowPublisher); StepVerifier.create(flux) @@ -78,14 +78,14 @@ void basic() { @Test void flush() { - Flow.Publisher flowPublisher = OutputStreamPublisher.create(outputStream -> { + Flow.Publisher flowPublisher = new OutputStreamPublisher<>(outputStream -> { outputStream.write(FOO); outputStream.flush(); outputStream.write(BAR); outputStream.flush(); outputStream.write(BAZ); outputStream.flush(); - }, this.byteMapper, this.executor); + }, this.byteMapper, this.executor, null); Flux flux = toString(flowPublisher); StepVerifier.create(flux) @@ -97,7 +97,7 @@ void flush() { @Test void chunkSize() { - Flow.Publisher flowPublisher = OutputStreamPublisher.create(outputStream -> { + Flow.Publisher flowPublisher = new OutputStreamPublisher<>(outputStream -> { outputStream.write(FOO); outputStream.write(BAR); outputStream.write(BAZ); @@ -115,7 +115,7 @@ void chunkSize() { void cancel() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); - Flow.Publisher flowPublisher = OutputStreamPublisher.create(outputStream -> { + Flow.Publisher flowPublisher = new OutputStreamPublisher<>(outputStream -> { assertThatIOException() .isThrownBy(() -> { outputStream.write(FOO); @@ -126,7 +126,7 @@ void cancel() throws InterruptedException { .withMessage("Subscription has been terminated"); latch.countDown(); - }, this.byteMapper, this.executor); + }, this.byteMapper, this.executor, null); Flux flux = toString(flowPublisher); StepVerifier.create(flux, 1) @@ -141,14 +141,14 @@ void cancel() throws InterruptedException { void closed() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); - Flow.Publisher flowPublisher = OutputStreamPublisher.create(outputStream -> { + Flow.Publisher flowPublisher = new OutputStreamPublisher<>(outputStream -> { OutputStreamWriter writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8); writer.write("foo"); writer.close(); assertThatIOException().isThrownBy(() -> writer.write("bar")) .withMessage("Stream closed"); latch.countDown(); - }, this.byteMapper, this.executor); + }, this.byteMapper, this.executor, null); Flux flux = toString(flowPublisher); StepVerifier.create(flux) @@ -162,7 +162,7 @@ void closed() throws InterruptedException { void negativeRequestN() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); - Flow.Publisher flowPublisher = OutputStreamPublisher.create(outputStream -> { + Flow.Publisher flowPublisher = new OutputStreamPublisher<>(outputStream -> { try (outputStream) { outputStream.write(FOO); outputStream.flush(); @@ -172,7 +172,7 @@ void negativeRequestN() throws InterruptedException { finally { latch.countDown(); } - }, this.byteMapper, this.executor); + }, this.byteMapper, this.executor, null); Flow.Subscription[] subscriptions = new Flow.Subscription[1]; Flux flux = toString(a-> flowPublisher.subscribe(new Flow.Subscriber<>() { @Override diff --git a/spring-web/src/test/java/org/springframework/http/client/ReactorNettyClientRequestFactoryTests.java b/spring-web/src/test/java/org/springframework/http/client/ReactorClientHttpRequestFactoryTests.java similarity index 83% rename from spring-web/src/test/java/org/springframework/http/client/ReactorNettyClientRequestFactoryTests.java rename to spring-web/src/test/java/org/springframework/http/client/ReactorClientHttpRequestFactoryTests.java index 6c3a34b3295d..72466335a7cf 100644 --- a/spring-web/src/test/java/org/springframework/http/client/ReactorNettyClientRequestFactoryTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/ReactorClientHttpRequestFactoryTests.java @@ -30,11 +30,11 @@ * @author Sebastien Deleuze * @since 6.1 */ -class ReactorNettyClientRequestFactoryTests extends AbstractHttpRequestFactoryTests { +class ReactorClientHttpRequestFactoryTests extends AbstractHttpRequestFactoryTests { @Override protected ClientHttpRequestFactory createRequestFactory() { - return new ReactorNettyClientRequestFactory(); + return new ReactorClientHttpRequestFactory(); } @Override @@ -46,7 +46,7 @@ void httpMethods() throws Exception { @Test void restartWithDefaultConstructor() { - ReactorNettyClientRequestFactory requestFactory = new ReactorNettyClientRequestFactory(); + ReactorClientHttpRequestFactory requestFactory = new ReactorClientHttpRequestFactory(); assertThat(requestFactory.isRunning()).isTrue(); requestFactory.start(); assertThat(requestFactory.isRunning()).isTrue(); @@ -59,7 +59,7 @@ void restartWithDefaultConstructor() { @Test void restartWithHttpClient() { HttpClient httpClient = HttpClient.create(); - ReactorNettyClientRequestFactory requestFactory = new ReactorNettyClientRequestFactory(httpClient); + ReactorClientHttpRequestFactory requestFactory = new ReactorClientHttpRequestFactory(httpClient); assertThat(requestFactory.isRunning()).isTrue(); requestFactory.start(); assertThat(requestFactory.isRunning()).isTrue(); @@ -74,7 +74,7 @@ void restartWithExternalResourceFactory() { ReactorResourceFactory resourceFactory = new ReactorResourceFactory(); resourceFactory.afterPropertiesSet(); Function mapper = Function.identity(); - ReactorNettyClientRequestFactory requestFactory = new ReactorNettyClientRequestFactory(resourceFactory, mapper); + ReactorClientHttpRequestFactory requestFactory = new ReactorClientHttpRequestFactory(resourceFactory, mapper); assertThat(requestFactory.isRunning()).isTrue(); requestFactory.start(); assertThat(requestFactory.isRunning()).isTrue(); @@ -88,7 +88,7 @@ void restartWithExternalResourceFactory() { void lateStartWithExternalResourceFactory() { ReactorResourceFactory resourceFactory = new ReactorResourceFactory(); Function mapper = Function.identity(); - ReactorNettyClientRequestFactory requestFactory = new ReactorNettyClientRequestFactory(resourceFactory, mapper); + ReactorClientHttpRequestFactory requestFactory = new ReactorClientHttpRequestFactory(resourceFactory, mapper); assertThat(requestFactory.isRunning()).isFalse(); resourceFactory.start(); requestFactory.start(); diff --git a/spring-web/src/test/java/org/springframework/http/client/SimpleClientHttpResponseTests.java b/spring-web/src/test/java/org/springframework/http/client/SimpleClientHttpResponseTests.java index 1dd50d93cd25..373709177400 100644 --- a/spring-web/src/test/java/org/springframework/http/client/SimpleClientHttpResponseTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/SimpleClientHttpResponseTests.java @@ -49,7 +49,7 @@ class SimpleClientHttpResponseTests { @Test // SPR-14040 public void shouldNotCloseConnectionWhenResponseClosed() throws Exception { TestByteArrayInputStream is = new TestByteArrayInputStream("Spring".getBytes(StandardCharsets.UTF_8)); - given(this.connection.getErrorStream()).willReturn(null); + given(this.connection.getResponseCode()).willReturn(200); given(this.connection.getInputStream()).willReturn(is); InputStream responseStream = this.response.getBody(); @@ -64,7 +64,7 @@ public void shouldNotCloseConnectionWhenResponseClosed() throws Exception { public void shouldDrainStreamWhenResponseClosed() throws Exception { byte[] buf = new byte[6]; TestByteArrayInputStream is = new TestByteArrayInputStream("SpringSpring".getBytes(StandardCharsets.UTF_8)); - given(this.connection.getErrorStream()).willReturn(null); + given(this.connection.getResponseCode()).willReturn(200); given(this.connection.getInputStream()).willReturn(is); InputStream responseStream = this.response.getBody(); @@ -82,6 +82,7 @@ public void shouldDrainStreamWhenResponseClosed() throws Exception { public void shouldDrainErrorStreamWhenResponseClosed() throws Exception { byte[] buf = new byte[6]; TestByteArrayInputStream is = new TestByteArrayInputStream("SpringSpring".getBytes(StandardCharsets.UTF_8)); + given(this.connection.getResponseCode()).willReturn(404); given(this.connection.getErrorStream()).willReturn(is); InputStream responseStream = this.response.getBody(); @@ -98,6 +99,7 @@ public void shouldDrainErrorStreamWhenResponseClosed() throws Exception { @Test // SPR-16773 public void shouldNotDrainWhenErrorStreamClosed() throws Exception { InputStream is = mock(); + given(this.connection.getResponseCode()).willReturn(404); given(this.connection.getErrorStream()).willReturn(is); willDoNothing().given(is).close(); given(is.transferTo(any())).willCallRealMethod(); @@ -115,7 +117,7 @@ public void shouldNotDrainWhenErrorStreamClosed() throws Exception { @Test // SPR-17181 public void shouldDrainResponseEvenIfResponseNotRead() throws Exception { TestByteArrayInputStream is = new TestByteArrayInputStream("SpringSpring".getBytes(StandardCharsets.UTF_8)); - given(this.connection.getErrorStream()).willReturn(null); + given(this.connection.getResponseCode()).willReturn(200); given(this.connection.getInputStream()).willReturn(is); this.response.close(); diff --git a/spring-web/src/test/java/org/springframework/http/client/SubscriberInputStreamTests.java b/spring-web/src/test/java/org/springframework/http/client/SubscriberInputStreamTests.java new file mode 100644 index 000000000000..31e7d096c12c --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/client/SubscriberInputStreamTests.java @@ -0,0 +1,236 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.client; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.Flow; + +import org.junit.jupiter.api.Test; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; + +/** + * Unit tests for {@link SubscriberInputStream}. + * + * @author Arjen Poutsma + * @author Oleh Dokuka + */ +class SubscriberInputStreamTests { + + private static final byte[] FOO = "foo".getBytes(UTF_8); + + private static final byte[] BAR = "bar".getBytes(UTF_8); + + private static final byte[] BAZ = "baz".getBytes(UTF_8); + + + private final Executor executor = Executors.newSingleThreadExecutor(); + + private final OutputStreamPublisher.ByteMapper byteMapper = + new OutputStreamPublisher.ByteMapper<>() { + + @Override + public byte[] map(int b) { + return new byte[] {(byte) b}; + } + + @Override + public byte[] map(byte[] b, int off, int len) { + byte[] result = new byte[len]; + System.arraycopy(b, off, result, 0, len); + return result; + } + }; + + + @Test + void basic() throws IOException { + Flow.Publisher publisher = new OutputStreamPublisher<>( + out -> { + out.write(FOO); + out.flush(); + out.write(BAR); + out.flush(); + out.write(BAZ); + out.flush(); + }, + this.byteMapper, this.executor, null); + + + try (SubscriberInputStream is = new SubscriberInputStream<>(s -> s, s -> {}, 1)) { + publisher.subscribe(is); + + byte[] chunk = new byte[3]; + + assertThat(is.read(chunk)).isEqualTo(3); + assertThat(chunk).containsExactly(FOO); + + assertThat(is.read(chunk)).isEqualTo(3); + assertThat(chunk).containsExactly(BAR); + + assertThat(is.read(chunk)).isEqualTo(3); + assertThat(chunk).containsExactly(BAZ); + + assertThat(is.read(chunk)).isEqualTo(-1); + } + } + + @Test + void chunkSize() throws Exception { + Flow.Publisher publisher = new OutputStreamPublisher<>( + out -> { + out.write(FOO); + out.write(BAR); + out.write(BAZ); + }, + this.byteMapper, this.executor, 2); + + try (SubscriberInputStream is = new SubscriberInputStream<>(s -> s, s -> {}, 1)) { + publisher.subscribe(is); + + StringBuilder sb = new StringBuilder(); + byte[] chunk = new byte[3]; + + sb.append((char) is.read()); + assertThat(sb).matches("f"); + + assertThat(is.read(chunk)).isEqualTo(3); + sb.append(new String(chunk, UTF_8)); + assertThat(sb).matches("foob"); + + assertThat(is.read(chunk)).isEqualTo(3); + sb.append(new String(chunk, UTF_8)); + assertThat(sb).matches("foobarb"); + + assertThat(is.read(chunk)).isEqualTo(2); + sb.append(new String(chunk,0, 2, UTF_8)); + assertThat(sb).matches("foobarbaz"); + + assertThat(is.read()).isEqualTo(-1); + } + } + + @Test + void cancel() throws Exception { + CountDownLatch latch1 = new CountDownLatch(1); + CountDownLatch latch2 = new CountDownLatch(1); + + Flow.Publisher publisher = new OutputStreamPublisher<>( + out -> { + assertThatIOException() + .isThrownBy(() -> { + out.write(FOO); + out.flush(); + out.write(BAR); + out.flush(); + latch1.countDown(); + out.write(BAZ); + out.flush(); + }) + .withMessage("Subscription has been terminated"); + latch2.countDown(); + }, this.byteMapper, this.executor, null); + + List discarded = new ArrayList<>(); + + try (SubscriberInputStream is = new SubscriberInputStream<>(s -> s, discarded::add, 1)) { + publisher.subscribe(is); + byte[] chunk = new byte[3]; + + assertThat(is.read(chunk)).isEqualTo(3); + assertThat(chunk).containsExactly(FOO); + + latch1.await(); + } + + latch2.await(); + assertThat(discarded).containsExactly("bar".getBytes(UTF_8)); + } + + @Test + void closed() throws InterruptedException, IOException { + CountDownLatch latch = new CountDownLatch(1); + + Flow.Publisher publisher = new OutputStreamPublisher<>( + out -> { + OutputStreamWriter writer = new OutputStreamWriter(out, UTF_8); + writer.write("foo"); + writer.close(); + assertThatIOException().isThrownBy(() -> writer.write("bar")).withMessage("Stream closed"); + latch.countDown(); + }, + this.byteMapper, this.executor, null); + + try (SubscriberInputStream is = new SubscriberInputStream<>(s -> s, s -> {}, 1)) { + publisher.subscribe(is); + byte[] chunk = new byte[3]; + + assertThat(is.read(chunk)).isEqualTo(3); + assertThat(chunk).containsExactly(FOO); + + assertThat(is.read(chunk)).isEqualTo(-1); + } + + latch.await(); + } + + @Test + void mapperThrowsException() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + + Flow.Publisher publisher = new OutputStreamPublisher<>( + out -> { + out.write(FOO); + out.flush(); + assertThatIOException() + .isThrownBy(() -> { + out.write(BAR); + out.flush(); + }) + .withMessage("Subscription has been terminated"); + latch.countDown(); + }, + this.byteMapper, this.executor, null); + + Throwable savedEx = null; + + StringBuilder sb = new StringBuilder(); + try (SubscriberInputStream is = new SubscriberInputStream<>( + s -> { throw new NullPointerException("boom"); }, s -> {}, 1)) { + + publisher.subscribe(is); + sb.append(new String(new byte[] {(byte) is.read()}, UTF_8)); + } + catch (Throwable ex) { + savedEx = ex; + } + + latch.await(); + + assertThat(sb.toString()).isEqualTo(""); + assertThat(savedEx).hasMessage("boom"); + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/client/observation/DefaultClientRequestObservationConventionTests.java b/spring-web/src/test/java/org/springframework/http/client/observation/DefaultClientRequestObservationConventionTests.java index dea5002df035..984f7b26ee00 100644 --- a/spring-web/src/test/java/org/springframework/http/client/observation/DefaultClientRequestObservationConventionTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/observation/DefaultClientRequestObservationConventionTests.java @@ -88,6 +88,17 @@ void addsKeyValuesForRequestWithUriTemplateWithHost() { assertThat(this.observationConvention.getHighCardinalityKeyValues(context)).contains(KeyValue.of("http.url", "https://example.org/resource/42")); } + @Test + void addsKeyValuesForRequestWithUriTemplateWithHostAndQuery() { + ClientRequestObservationContext context = createContext( + new MockClientHttpRequest(HttpMethod.GET, "https://example.org/resource/{id}?queryKey={queryValue}", 42, "Query"), response); + context.setUriTemplate("https://example.org/resource/{id}?queryKey={queryValue}"); + assertThat(this.observationConvention.getLowCardinalityKeyValues(context)) + .contains(KeyValue.of("exception", "none"), KeyValue.of("method", "GET"), KeyValue.of("uri", "/resource/{id}?queryKey={queryValue}"), + KeyValue.of("status", "200"), KeyValue.of("client.name", "example.org"), KeyValue.of("outcome", "SUCCESS")); + assertThat(this.observationConvention.getHighCardinalityKeyValues(context)).contains(KeyValue.of("http.url", "https://example.org/resource/42?queryKey=Query")); + } + @Test void addsKeyValuesForRequestWithUriTemplateWithoutPath() { ClientRequestObservationContext context = createContext( diff --git a/spring-web/src/test/java/org/springframework/http/client/reactive/ClientHttpConnectorTests.java b/spring-web/src/test/java/org/springframework/http/client/reactive/ClientHttpConnectorTests.java index 84fd36860efe..dcd98a835ff1 100644 --- a/spring-web/src/test/java/org/springframework/http/client/reactive/ClientHttpConnectorTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/reactive/ClientHttpConnectorTests.java @@ -39,9 +39,11 @@ import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import okio.Buffer; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -64,6 +66,7 @@ * Tests for {@link ClientHttpConnector} implementations. * @author Arjen Poutsma * @author Brian Clozel + * @author Sebastien Deleuze */ class ClientHttpConnectorTests { @@ -198,6 +201,28 @@ void cookieExpireValueSetAsMaxAge(ClientHttpConnector connector) { .verifyComplete(); } + @Test + void disableCookieWithHttpComponents() { + ClientHttpConnector connector = new HttpComponentsClientHttpConnector( + HttpAsyncClientBuilder.create().disableCookieManagement().build() + ); + + prepareResponse(response -> { + response.setResponseCode(200); + response.addHeader("Set-Cookie", "id=test;"); + }); + Mono futureResponse = + connector.connect(HttpMethod.GET, this.server.url("/").uri(), ReactiveHttpOutputMessage::setComplete); + StepVerifier.create(futureResponse) + .assertNext(response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getCookies()).isEmpty(); + } + ) + .verifyComplete(); + + } + private Buffer randomBody(int size) { Buffer responseBody = new Buffer(); Random rnd = new Random(); diff --git a/spring-web/src/test/java/org/springframework/http/codec/CancelWithoutDemandCodecTests.java b/spring-web/src/test/java/org/springframework/http/codec/CancelWithoutDemandCodecTests.java index eed700ecb3ec..62aa65fefeec 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/CancelWithoutDemandCodecTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/CancelWithoutDemandCodecTests.java @@ -83,7 +83,7 @@ public void cancelWithJackson() { MediaType.APPLICATION_JSON, Collections.emptyMap()); BaseSubscriber subscriber = new ZeroDemandSubscriber(); - flux.subscribe(subscriber); // Assume sync execution (e.g. encoding with Flux.just) + flux.subscribe(subscriber); // Assume sync execution (for example, encoding with Flux.just) subscriber.cancel(); } @@ -96,7 +96,7 @@ public void cancelWithJaxb2() { MediaType.APPLICATION_XML, Collections.emptyMap()); BaseSubscriber subscriber = new ZeroDemandSubscriber(); - flux.subscribe(subscriber); // Assume sync execution (e.g. encoding with Flux.just) + flux.subscribe(subscriber); // Assume sync execution (for example, encoding with Flux.just) subscriber.cancel(); } @@ -110,7 +110,7 @@ public void cancelWithProtobufEncoder() { MediaType.APPLICATION_PROTOBUF, Collections.emptyMap()); BaseSubscriber subscriber = new ZeroDemandSubscriber(); - flux.subscribe(subscriber); // Assume sync execution (e.g. encoding with Flux.just) + flux.subscribe(subscriber); // Assume sync execution (for example, encoding with Flux.just) subscriber.cancel(); } @@ -187,7 +187,7 @@ public boolean isCommitted() { public Mono writeWith(Publisher body) { Flux flux = Flux.from(body); BaseSubscriber subscriber = new ZeroDemandSubscriber(); - flux.subscribe(subscriber); // Assume sync execution (e.g. encoding with Flux.just) + flux.subscribe(subscriber); // Assume sync execution (for example, encoding with Flux.just) subscriber.cancel(); return Mono.empty(); } @@ -196,7 +196,7 @@ public Mono writeWith(Publisher body) { public Mono writeAndFlushWith(Publisher> body) { Flux flux = Flux.from(body).concatMap(Flux::from); BaseSubscriber subscriber = new ZeroDemandSubscriber(); - flux.subscribe(subscriber); // Assume sync execution (e.g. encoding with Flux.just) + flux.subscribe(subscriber); // Assume sync execution (for example, encoding with Flux.just) subscriber.cancel(); return Mono.empty(); } diff --git a/spring-web/src/test/java/org/springframework/http/codec/EncoderHttpMessageWriterTests.java b/spring-web/src/test/java/org/springframework/http/codec/EncoderHttpMessageWriterTests.java index eff738c3c9f0..58479195e16f 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/EncoderHttpMessageWriterTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/EncoderHttpMessageWriterTests.java @@ -33,7 +33,9 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import org.springframework.core.ResolvableType; import org.springframework.core.codec.CharSequenceEncoder; +import org.springframework.core.codec.Encoder; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.http.MediaType; @@ -199,6 +201,30 @@ void isStreamingMediaType() throws InvocationTargetException, IllegalAccessExcep assertThat((Boolean) method.invoke(writer, TEXT_HTML)).isFalse(); } + @Test + public void noContentTypeWithEmptyBody() { + Encoder encoder = CharSequenceEncoder.textPlainOnly(); + HttpMessageWriter writer = new EncoderHttpMessageWriter<>(encoder); + Mono writerMono = writer.write(Mono.empty(), ResolvableType.forClass(String.class), + null, this.response, NO_HINTS); + + StepVerifier.create(writerMono) + .verifyComplete(); + assertThat(response.getHeaders().getContentType()).isNull(); + } + + @Test + public void zeroContentLengthWithEmptyBody() { + Encoder encoder = CharSequenceEncoder.textPlainOnly(); + HttpMessageWriter writer = new EncoderHttpMessageWriter<>(encoder); + Mono writerMono = writer.write(Mono.empty(), ResolvableType.forClass(String.class), + null, this.response, NO_HINTS); + + StepVerifier.create(writerMono) + .verifyComplete(); + assertThat(this.response.getHeaders().getContentLength()).isEqualTo(0); + } + private void configureEncoder(MimeType... mimeTypes) { configureEncoder(Flux.empty(), mimeTypes); } diff --git a/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageReaderTests.java b/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageReaderTests.java index bbec03d2dae1..7b0e221dbaa0 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageReaderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageReaderTests.java @@ -53,7 +53,7 @@ void canRead() { MediaType.APPLICATION_FORM_URLENCODED)).isTrue(); assertThat(this.reader.canRead( - ResolvableType.forInstance(new LinkedMultiValueMap()), + ResolvableType.forInstance(new LinkedMultiValueMap<>()), MediaType.APPLICATION_FORM_URLENCODED)).isTrue(); assertThat(this.reader.canRead( diff --git a/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageWriterTests.java b/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageWriterTests.java index e51b297db2fe..6d66ee2fd0ce 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageWriterTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageWriterTests.java @@ -52,7 +52,7 @@ void canWrite() { // No generic information assertThat(this.writer.canWrite( - ResolvableType.forInstance(new LinkedMultiValueMap()), + ResolvableType.forInstance(new LinkedMultiValueMap<>()), MediaType.APPLICATION_FORM_URLENCODED)).isTrue(); assertThat(this.writer.canWrite( @@ -88,7 +88,7 @@ void writeForm() { .expectComplete() .verify(); HttpHeaders headers = response.getHeaders(); - assertThat(headers.getContentType().toString()).isEqualTo("application/x-www-form-urlencoded;charset=UTF-8"); + assertThat(headers.getContentType()).isEqualTo(MediaType.APPLICATION_FORM_URLENCODED); assertThat(headers.getContentLength()).isEqualTo(expected.length()); } diff --git a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageReaderTests.java b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageReaderTests.java index f2b9c7eaf3f8..a312cd3dbcb6 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageReaderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageReaderTests.java @@ -74,20 +74,10 @@ void readServerSentEvents() { request, Collections.emptyMap()).cast(ServerSentEvent.class); StepVerifier.create(events) - .consumeNextWith(event -> { - assertThat(event.id()).isEqualTo("c42"); - assertThat(event.event()).isEqualTo("foo"); - assertThat(event.retry()).isEqualTo(Duration.ofMillis(123)); - assertThat(event.comment()).isEqualTo("bla\nbla bla\nbla bla bla"); - assertThat(event.data()).isEqualTo("bar"); - }) - .consumeNextWith(event -> { - assertThat(event.id()).isEqualTo("c43"); - assertThat(event.event()).isEqualTo("bar"); - assertThat(event.retry()).isEqualTo(Duration.ofMillis(456)); - assertThat(event.comment()).isNull(); - assertThat(event.data()).isEqualTo("baz"); - }) + .expectNext(ServerSentEvent.builder().id("c42").event("foo") + .retry(Duration.ofMillis(123)).comment("bla\nbla bla\nbla bla bla").data("bar").build()) + .expectNext(ServerSentEvent.builder().id("c43").event("bar") + .retry(Duration.ofMillis(456)).data("baz").build()) .consumeNextWith(event -> assertThat(event.data()).isNull()) .consumeNextWith(event -> assertThat(event.data()).isNull()) .expectComplete() @@ -108,20 +98,10 @@ void readServerSentEventsWithMultipleChunks() { request, Collections.emptyMap()).cast(ServerSentEvent.class); StepVerifier.create(events) - .consumeNextWith(event -> { - assertThat(event.id()).isEqualTo("c42"); - assertThat(event.event()).isEqualTo("foo"); - assertThat(event.retry()).isEqualTo(Duration.ofMillis(123)); - assertThat(event.comment()).isEqualTo("bla\nbla bla\nbla bla bla"); - assertThat(event.data()).isEqualTo("bar"); - }) - .consumeNextWith(event -> { - assertThat(event.id()).isEqualTo("c43"); - assertThat(event.event()).isEqualTo("bar"); - assertThat(event.retry()).isEqualTo(Duration.ofMillis(456)); - assertThat(event.comment()).isNull(); - assertThat(event.data()).isEqualTo("baz"); - }) + .expectNext(ServerSentEvent.builder().id("c42").event("foo") + .retry(Duration.ofMillis(123)).comment("bla\nbla bla\nbla bla bla").data("bar").build()) + .expectNext(ServerSentEvent.builder().id("c43").event("bar") + .retry(Duration.ofMillis(456)).data("baz").build()) .expectComplete() .verify(); } diff --git a/spring-web/src/test/java/org/springframework/http/codec/json/Jackson2JsonDecoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/json/Jackson2JsonDecoderTests.java index 449ddfddfa0d..ea1406dea52c 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/json/Jackson2JsonDecoderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/json/Jackson2JsonDecoderTests.java @@ -46,6 +46,7 @@ import org.springframework.http.codec.json.JacksonViewBean.MyJacksonView1; import org.springframework.http.codec.json.JacksonViewBean.MyJacksonView3; import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; import org.springframework.web.testfixture.xml.Pojo; import static org.assertj.core.api.Assertions.assertThat; @@ -172,6 +173,22 @@ protected void decodeToMono() { .verify(), null, null); } + @Test + void decodeToFluxWithListElements() { + Flux input = Flux.concat( + stringBuffer("[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\",\"foo\":\"f2\"}]"), + stringBuffer("[{\"bar\":\"b3\",\"foo\":\"f3\"},{\"bar\":\"b4\",\"foo\":\"f4\"}]")); + + ResolvableType elementType = ResolvableType.forClassWithGenerics(List.class, Pojo.class); + + testDecodeAll(input, elementType, + step -> step + .expectNext(List.of(pojo1, pojo2)) + .expectNext(List.of(new Pojo("f3", "b3"), new Pojo("f4", "b4"))) + .verifyComplete(), + MimeTypeUtils.APPLICATION_JSON, + Collections.emptyMap()); + } @Test void decodeEmptyArrayToFlux() { @@ -386,7 +403,7 @@ private static class Deserializer extends StdDeserializer { private static final long serialVersionUID = 1L; - protected Deserializer() { + Deserializer() { super(TestObject.class); } diff --git a/spring-web/src/test/java/org/springframework/http/codec/json/Jackson2JsonEncoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/json/Jackson2JsonEncoderTests.java index cc8400a0a30a..851b4406b906 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/json/Jackson2JsonEncoderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/json/Jackson2JsonEncoderTests.java @@ -152,7 +152,7 @@ void encodeNonStreamEmpty() { .verifyComplete()); } - @Test // gh-29038 + @Test // gh-29038 void encodeNonStreamWithErrorAsFirstSignal() { String message = "I'm a teapot"; Flux input = Flux.error(new IllegalStateException(message)); @@ -262,10 +262,11 @@ public void jacksonValueUnwrappedBeforeObjectMapperSelection() { ObjectMapper mapper = new ObjectMapper().configure(SerializationFeature.INDENT_OUTPUT, true); this.encoder.registerObjectMappersForType(JacksonViewBean.class, map -> map.put(halMediaType, mapper)); - String ls = System.lineSeparator(); // output below is different between Unix and Windows testEncode(Mono.just(jacksonValue), type, halMediaType, Collections.emptyMap(), step -> step - .consumeNextWith(expectString("{" + ls + " \"withView1\" : \"with\"" + ls + "}") - ) + .consumeNextWith(expectString(""" + { + \s "withView1" : "with" + }""")) .verifyComplete() ); } 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 4c10a24e1d71..209e358a5292 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 @@ -55,8 +55,7 @@ import static java.util.Collections.emptyMap; import static java.util.Collections.singletonMap; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Named.named; -import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.junit.jupiter.params.provider.Arguments.argumentSet; import static org.springframework.core.ResolvableType.forClass; import static org.springframework.core.io.buffer.DataBufferUtils.release; @@ -430,7 +429,7 @@ protected void hookOnNext(DataBuffer buffer) { @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) - @ParameterizedTest(name = "[{index}] {0}") + @ParameterizedTest @MethodSource("org.springframework.http.codec.multipart.DefaultPartHttpMessageReaderTests#messageReaders()") @interface ParameterizedDefaultPartHttpMessageReaderTest { } @@ -443,8 +442,8 @@ static Stream messageReaders() { onDisk.setMaxInMemorySize(100); return Stream.of( - arguments(named("in-memory", inMemory)), - arguments(named("on-disk", onDisk))); + argumentSet("in-memory", inMemory), + argumentSet("on-disk", onDisk)); } } diff --git a/spring-web/src/test/java/org/springframework/http/codec/protobuf/ProtobufJsonDecoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/protobuf/ProtobufJsonDecoderTests.java new file mode 100644 index 000000000000..07eb52a786cf --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/protobuf/ProtobufJsonDecoderTests.java @@ -0,0 +1,89 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.codec.protobuf; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.DecodingException; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.testfixture.codec.AbstractDecoderTests; +import org.springframework.http.MediaType; +import org.springframework.protobuf.Msg; +import org.springframework.protobuf.SecondMsg; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ProtobufJsonDecoder}. + * @author Brian Clozel + */ +public class ProtobufJsonDecoderTests extends AbstractDecoderTests { + + private Msg msg1 = Msg.newBuilder().setFoo("Foo").setBlah(SecondMsg.newBuilder().setBlah(123).build()).build(); + + public ProtobufJsonDecoderTests() { + super(new ProtobufJsonDecoder()); + } + + @Test + @Override + protected void canDecode() throws Exception { + ResolvableType msgType = ResolvableType.forClass(Msg.class); + assertThat(this.decoder.canDecode(msgType, null)).isFalse(); + assertThat(this.decoder.canDecode(msgType, MediaType.APPLICATION_JSON)).isTrue(); + assertThat(this.decoder.canDecode(msgType, MediaType.APPLICATION_PROTOBUF)).isFalse(); + assertThat(this.decoder.canDecode(ResolvableType.forClass(Object.class), MediaType.APPLICATION_JSON)).isFalse(); + } + + @Test + @Override + protected void decode() throws Exception { + ResolvableType msgType = ResolvableType.forClass(Msg.class); + Flux input = Flux.just(dataBuffer("[{\"foo\":\"Foo\",\"blah\":{\"blah\":123}}"), + dataBuffer(",{\"foo\":\"Bar\",\"blah\":{\"blah\":456}}"), + dataBuffer("]")); + + testDecode(input, msgType, step -> step.consumeErrorWith(error -> assertThat(error).isInstanceOf(UnsupportedOperationException.class)), + MediaType.APPLICATION_JSON, null); + } + + @Test + @Override + protected void decodeToMono() throws Exception { + DataBuffer dataBuffer = dataBuffer("{\"foo\":\"Foo\",\"blah\":{\"blah\":123}}"); + testDecodeToMonoAll(Mono.just(dataBuffer), Msg.class, step -> step + .expectNext(this.msg1) + .verifyComplete()); + } + + @Test + void exceedMaxSize() { + this.decoder.setMaxMessageSize(1); + DataBuffer first = dataBuffer("{\"foo\":\"Foo\","); + DataBuffer second = dataBuffer("\"blah\":{\"blah\":123}}"); + + testDecodeToMono(Flux.just(first, second), Msg.class, step -> step.verifyError(DecodingException.class)); + } + + private DataBuffer dataBuffer(String json) { + return this.bufferFactory.wrap(json.getBytes()); + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoderTests.java new file mode 100644 index 000000000000..3a6fce099ddc --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoderTests.java @@ -0,0 +1,120 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.codec.protobuf; + +import java.nio.charset.StandardCharsets; + +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.testfixture.codec.AbstractEncoderTests; +import org.springframework.core.testfixture.io.buffer.DataBufferTestUtils; +import org.springframework.http.MediaType; +import org.springframework.protobuf.Msg; +import org.springframework.protobuf.SecondMsg; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.core.ResolvableType.forClass; + +/** + * Tests for {@link ProtobufJsonEncoder}. + * @author Brian Clozel + */ +class ProtobufJsonEncoderTests extends AbstractEncoderTests { + + private Msg msg1 = + Msg.newBuilder().setFoo("Foo").setBlah(SecondMsg.newBuilder().setBlah(123).build()).build(); + + private Msg msg2 = + Msg.newBuilder().setFoo("Bar").setBlah(SecondMsg.newBuilder().setBlah(456).build()).build(); + + public ProtobufJsonEncoderTests() { + super(new ProtobufJsonEncoder(JsonFormat.printer().omittingInsignificantWhitespace())); + } + + @Override + @Test + protected void canEncode() throws Exception { + assertThat(this.encoder.canEncode(forClass(Msg.class), null)).isFalse(); + assertThat(this.encoder.canEncode(forClass(Msg.class), MediaType.APPLICATION_JSON)).isTrue(); + assertThat(this.encoder.canEncode(forClass(Msg.class), MediaType.APPLICATION_NDJSON)).isFalse(); + assertThat(this.encoder.canEncode(forClass(Object.class), MediaType.APPLICATION_JSON)).isFalse(); + } + + @Override + @Test + protected void encode() throws Exception { + Mono input = Mono.just(this.msg1); + ResolvableType inputType = forClass(Msg.class); + + testEncode(input, inputType, MediaType.APPLICATION_JSON, null, step -> step + .assertNext(dataBuffer -> assertBufferEqualsJson(dataBuffer, "{\"foo\":\"Foo\",\"blah\":{\"blah\":123}}")) + .verifyComplete()); + testEncodeError(input, inputType, MediaType.APPLICATION_JSON, null); + testEncodeCancel(input, inputType, MediaType.APPLICATION_JSON, null); + } + + @Test + void encodeEmptyMono() { + Mono input = Mono.empty(); + ResolvableType inputType = forClass(Msg.class); + Flux result = this.encoder.encode(input, this.bufferFactory, inputType, + MediaType.APPLICATION_JSON, null); + StepVerifier.create(result) + .verifyComplete(); + } + + @Test + void encodeStream() { + Flux input = Flux.just(this.msg1, this.msg2); + ResolvableType inputType = forClass(Msg.class); + + testEncode(input, inputType, MediaType.APPLICATION_JSON, null, step -> step + .assertNext(dataBuffer -> assertBufferEqualsJson(dataBuffer, "[{\"foo\":\"Foo\",\"blah\":{\"blah\":123}}")) + .assertNext(dataBuffer -> assertBufferEqualsJson(dataBuffer, ",{\"foo\":\"Bar\",\"blah\":{\"blah\":456}}")) + .assertNext(dataBuffer -> assertBufferEqualsJson(dataBuffer, "]")) + .verifyComplete()); + } + + @Test + void encodeEmptyFlux() { + Flux input = Flux.empty(); + ResolvableType inputType = forClass(Msg.class); + Flux result = this.encoder.encode(input, this.bufferFactory, inputType, + MediaType.APPLICATION_JSON, null); + StepVerifier.create(result) + .assertNext(buffer -> assertBufferEqualsJson(buffer, "[")) + .assertNext(buffer -> assertBufferEqualsJson(buffer, "]")) + .verifyComplete(); + } + + + private void assertBufferEqualsJson(DataBuffer actual, String expected) { + byte[] bytes = DataBufferTestUtils.dumpBytes(actual); + String json = new String(bytes, StandardCharsets.UTF_8); + assertThat(json).isEqualTo(expected); + DataBufferUtils.release(actual); + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/support/CodecConfigurerTests.java b/spring-web/src/test/java/org/springframework/http/codec/support/CodecConfigurerTests.java index 3f5ec01f3904..1b3b9ee5368a 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/support/CodecConfigurerTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/support/CodecConfigurerTests.java @@ -385,12 +385,12 @@ void cloneDefaultCodecs() { // Clone has the customized the customizations List> decoders = clone.getReaders().stream() - .filter(reader -> reader instanceof DecoderHttpMessageReader) + .filter(DecoderHttpMessageReader.class::isInstance) .map(reader -> ((DecoderHttpMessageReader) reader).getDecoder()) .collect(Collectors.toList()); List> encoders = clone.getWriters().stream() - .filter(writer -> writer instanceof EncoderHttpMessageWriter) + .filter(EncoderHttpMessageWriter.class::isInstance) .map(reader -> ((EncoderHttpMessageWriter) reader).getEncoder()) .collect(Collectors.toList()); @@ -400,12 +400,12 @@ void cloneDefaultCodecs() { // Original does not have the customizations decoders = this.configurer.getReaders().stream() - .filter(reader -> reader instanceof DecoderHttpMessageReader) + .filter(DecoderHttpMessageReader.class::isInstance) .map(reader -> ((DecoderHttpMessageReader) reader).getDecoder()) .collect(Collectors.toList()); encoders = this.configurer.getWriters().stream() - .filter(writer -> writer instanceof EncoderHttpMessageWriter) + .filter(EncoderHttpMessageWriter.class::isInstance) .map(reader -> ((EncoderHttpMessageWriter) reader).getEncoder()) .collect(Collectors.toList()); @@ -454,7 +454,7 @@ private void assertStringEncoder(Encoder encoder, boolean textOnly) { private void assertDecoderInstance(Decoder decoder) { assertThat(this.configurer.getReaders().stream() - .filter(writer -> writer instanceof DecoderHttpMessageReader) + .filter(DecoderHttpMessageReader.class::isInstance) .map(writer -> ((DecoderHttpMessageReader) writer).getDecoder()) .filter(e -> decoder.getClass().equals(e.getClass())) .findFirst() @@ -463,7 +463,7 @@ private void assertDecoderInstance(Decoder decoder) { private void assertEncoderInstance(Encoder encoder) { assertThat(this.configurer.getWriters().stream() - .filter(writer -> writer instanceof EncoderHttpMessageWriter) + .filter(EncoderHttpMessageWriter.class::isInstance) .map(writer -> ((EncoderHttpMessageWriter) writer).getEncoder()) .filter(e -> encoder.getClass().equals(e.getClass())) .findFirst() diff --git a/spring-web/src/test/java/org/springframework/http/converter/FormHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/FormHttpMessageConverterTests.java index cf1c9c1a8da8..98c937bc7339 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/FormHttpMessageConverterTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/FormHttpMessageConverterTests.java @@ -47,6 +47,7 @@ import org.springframework.web.testfixture.http.MockHttpInputMessage; import org.springframework.web.testfixture.http.MockHttpOutputMessage; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; import static org.springframework.http.MediaType.APPLICATION_JSON; @@ -93,7 +94,7 @@ void canWrite() { assertCanWrite(MULTIPART_FORM_DATA); assertCanWrite(MULTIPART_MIXED); assertCanWrite(MULTIPART_RELATED); - assertCanWrite(new MediaType("multipart", "form-data", StandardCharsets.UTF_8)); + assertCanWrite(new MediaType("multipart", "form-data", UTF_8)); assertCanWrite(MediaType.ALL); assertCanWrite(null); } @@ -141,10 +142,10 @@ void writeForm() throws IOException { MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); this.converter.write(body, APPLICATION_FORM_URLENCODED, outputMessage); - assertThat(outputMessage.getBodyAsString(StandardCharsets.UTF_8)) + assertThat(outputMessage.getBodyAsString(UTF_8)) .as("Invalid result").isEqualTo("name+1=value+1&name+2=value+2%2B1&name+2=value+2%2B2&name+3"); - assertThat(outputMessage.getHeaders().getContentType().toString()) - .as("Invalid content-type").isEqualTo("application/x-www-form-urlencoded;charset=UTF-8"); + assertThat(outputMessage.getHeaders().getContentType()) + .as("Invalid content-type").isEqualTo(APPLICATION_FORM_URLENCODED); assertThat(outputMessage.getHeaders().getContentLength()) .as("Invalid content-length").isEqualTo(outputMessage.getBodyAsBytes().length); } @@ -178,7 +179,7 @@ public String getFilename() { parts.add("json", entity); Map parameters = new LinkedHashMap<>(2); - parameters.put("charset", StandardCharsets.UTF_8.name()); + parameters.put("charset", UTF_8.name()); parameters.put("foo", "bar"); MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); @@ -260,7 +261,7 @@ public String getFilename() { parts.add("xml", entity); Map parameters = new LinkedHashMap<>(2); - parameters.put("charset", StandardCharsets.UTF_8.name()); + parameters.put("charset", UTF_8.name()); parameters.put("foo", "bar"); MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); @@ -323,8 +324,8 @@ public void writeMultipartOrder() throws Exception { parts.add("part2", entity); MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); - this.converter.setMultipartCharset(StandardCharsets.UTF_8); - this.converter.write(parts, new MediaType("multipart", "form-data", StandardCharsets.UTF_8), outputMessage); + this.converter.setMultipartCharset(UTF_8); + this.converter.write(parts, new MediaType("multipart", "form-data", UTF_8), outputMessage); final MediaType contentType = outputMessage.getHeaders().getContentType(); assertThat(contentType.getParameter("boundary")).as("No boundary found").isNotNull(); diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilderTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilderTests.java index a6fc0b36f683..b8c927bac516 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilderTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilderTests.java @@ -79,6 +79,7 @@ import com.fasterxml.jackson.dataformat.smile.SmileFactory; import com.fasterxml.jackson.dataformat.xml.XmlFactory; import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import kotlin.ranges.IntRange; import org.junit.jupiter.api.Test; @@ -95,6 +96,7 @@ * * @author Sebastien Deleuze * @author Eddú Meléndez + * @author Hyoungjune Kim */ @SuppressWarnings("deprecation") class Jackson2ObjectMapperBuilderTests { @@ -588,6 +590,13 @@ void factory() { assertThat(objectMapper.getFactory().getClass()).isEqualTo(SmileFactory.class); } + @Test + void yaml() { + ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.yaml().build(); + assertThat(objectMapper).isNotNull(); + assertThat(objectMapper.getFactory().getClass()).isEqualTo(YAMLFactory.class); + } + @Test void visibility() throws JsonProcessingException { ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json() diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverterTests.java index a894b4a207b1..d62a9ce02126 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverterTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverterTests.java @@ -63,8 +63,6 @@ */ class MappingJackson2HttpMessageConverterTests { - protected static final String NEWLINE_SYSTEM_PROPERTY = System.lineSeparator(); - private final MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); @@ -369,8 +367,10 @@ void prettyPrint() throws Exception { this.converter.writeInternal(bean, null, outputMessage); String result = outputMessage.getBodyAsString(StandardCharsets.UTF_8); - assertThat(result).isEqualTo(("{" + NEWLINE_SYSTEM_PROPERTY + - " \"name\" : \"Jason\"" + NEWLINE_SYSTEM_PROPERTY + "}")); + assertThat(result).isEqualToNormalizingNewlines(""" + { + \s "name" : "Jason" + }"""); } @Test diff --git a/spring-web/src/test/java/org/springframework/http/converter/xml/Jaxb2RootElementHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/xml/Jaxb2RootElementHttpMessageConverterTests.java index a54aeb779464..6c8394015332 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/xml/Jaxb2RootElementHttpMessageConverterTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/xml/Jaxb2RootElementHttpMessageConverterTests.java @@ -18,6 +18,9 @@ import java.nio.charset.StandardCharsets; +import javax.xml.namespace.QName; + +import jakarta.xml.bind.JAXBElement; import jakarta.xml.bind.Marshaller; import jakarta.xml.bind.Unmarshaller; import jakarta.xml.bind.annotation.XmlAttribute; @@ -93,6 +96,8 @@ void canWrite() { .as("Converter does not support writing @XmlRootElement subclass").isTrue(); assertThat(converter.canWrite(rootElementCglib.getClass(), null)) .as("Converter does not support writing @XmlRootElement subclass").isTrue(); + assertThat(converter.canWrite(JAXBElement.class, null)) + .as("Converter does not support writing JAXBElement").isTrue(); assertThat(converter.canWrite(Type.class, null)) .as("Converter supports writing @XmlType").isFalse(); } @@ -170,9 +175,9 @@ void testXmlBomb() throws Exception { ]> &lol9;"""; MockHttpInputMessage inputMessage = new MockHttpInputMessage(content.getBytes(StandardCharsets.UTF_8)); - assertThatExceptionOfType(HttpMessageNotReadableException.class).isThrownBy(() -> - this.converter.read(RootElement.class, inputMessage)) - .withMessageContaining("DOCTYPE"); + assertThatExceptionOfType(HttpMessageNotReadableException.class) + .isThrownBy(() -> this.converter.read(RootElement.class, inputMessage)) + .withMessageContaining("DOCTYPE"); } @Test @@ -183,7 +188,19 @@ void writeXmlRootElement() throws Exception { .as("Invalid content-type").isEqualTo(MediaType.APPLICATION_XML); DifferenceEvaluator ev = chain(Default, downgradeDifferencesToEqual(XML_STANDALONE)); assertThat(XmlContent.of(outputMessage.getBodyAsString(StandardCharsets.UTF_8))) - .isSimilarTo("", ev); + .isSimilarTo("", ev); + } + + @Test + void writeJaxbElementRootElement() throws Exception { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + JAXBElement jaxbElement = new JAXBElement<>(new QName("custom"), MyCustomElement.class, new MyCustomElement("field1", "field2")); + converter.write(jaxbElement, null, outputMessage); + assertThat(outputMessage.getHeaders().getContentType()) + .as("Invalid content-type").isEqualTo(MediaType.APPLICATION_XML); + DifferenceEvaluator ev = chain(Default, downgradeDifferencesToEqual(XML_STANDALONE)); + assertThat(XmlContent.of(outputMessage.getBodyAsString(StandardCharsets.UTF_8))) + .isSimilarTo("field1field2", ev); } @Test 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 dcb98b145151..6dad7e7ab2e8 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 @@ -23,6 +23,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -31,6 +33,7 @@ import org.springframework.web.testfixture.servlet.MockHttpServletRequest; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** * @author Arjen Poutsma @@ -78,24 +81,28 @@ void getUriWithQueryString() { assertThat(request.getURI()).isEqualTo(uri); } - @Test // SPR-16414 - void getUriWithQueryParam() { + // gh-20960 + @ParameterizedTest(name = "{displayName}({arguments})") + @CsvSource(delimiter='|', value = { + "query=foo | ?query=foo", + "query=foo%%x | ?query=foo%25%25x" + }) + void getUriWithMalformedQueryParam(String inputQuery, String expectedQuery) { mockRequest.setScheme("https"); mockRequest.setServerPort(443); mockRequest.setServerName("example.com"); mockRequest.setRequestURI("/path"); - mockRequest.setQueryString("query=foo"); - assertThat(request.getURI()).isEqualTo(URI.create("https://example.com/path?query=foo")); + mockRequest.setQueryString(inputQuery); + assertThat(request.getURI()).isEqualTo(URI.create("https://example.com/path" + expectedQuery)); } - @Test // SPR-16414 - void getUriWithMalformedQueryParam() { + @Test + void getUriWithMalformedPath() { mockRequest.setScheme("https"); mockRequest.setServerPort(443); mockRequest.setServerName("example.com"); - mockRequest.setRequestURI("/path"); - mockRequest.setQueryString("query=foo%%x"); - assertThat(request.getURI()).isEqualTo(URI.create("https://example.com/path")); + mockRequest.setRequestURI("/p%th"); + assertThatIllegalStateException().isThrownBy(() -> request.getURI()); } @Test // SPR-13876 @@ -217,4 +224,10 @@ void getFormBodyWhenNotEncodedCharactersPresent() throws IOException { assertThat(request.getHeaders().getContentLength()).isEqualTo(result.length); } + @Test + void attributes() { + request.getAttributes().put("foo", "bar"); + assertThat(mockRequest.getAttribute("foo")).isEqualTo("bar"); + } + } diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/ChannelSendOperatorTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/ChannelSendOperatorTests.java index 6caa76349637..26eab2a58ab1 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/ChannelSendOperatorTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/ChannelSendOperatorTests.java @@ -179,7 +179,7 @@ void errorFromWriteFunctionWhileItemCached() { // 1. First item received // 2. writeFunction applied and writeCompletionBarrier subscribed to it - // 3. writeFunction fails, e.g. to flush status and headers, before request(n) from server + // 3. writeFunction fails, for example, to flush status and headers, before request(n) from server LeakAwareDataBufferFactory bufferFactory = new LeakAwareDataBufferFactory(); diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java index 8d2daafa972e..4ceee53b45c9 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java @@ -70,13 +70,32 @@ public void basicTest(HttpServer httpServer) throws Exception { List cookie0 = splitCookie(headerValues.get(0)); assertThat(cookie0.remove("SID=31d4d96e407aad42")).as("SID").isTrue(); assertThat(cookie0.stream().map(String::toLowerCase)) - .containsExactlyInAnyOrder("path=/", "secure", "httponly"); + .contains("path=/", "secure", "httponly"); List cookie1 = splitCookie(headerValues.get(1)); assertThat(cookie1.remove("lang=en-US")).as("lang").isTrue(); assertThat(cookie1.stream().map(String::toLowerCase)) .containsExactlyInAnyOrder("path=/", "domain=example.com"); } + @ParameterizedHttpServerTest + public void partitionedAttributeTest(HttpServer httpServer) throws Exception { + assumeFalse(httpServer instanceof UndertowHttpServer, "Undertow does not support Partitioned cookies"); + startServer(httpServer); + + URI url = URI.create("http://localhost:" + port); + String header = "SID=31d4d96e407aad42; lang=en-US"; + ResponseEntity response = new RestTemplate().exchange( + RequestEntity.get(url).header("Cookie", header).build(), Void.class); + + List headerValues = response.getHeaders().get("Set-Cookie"); + assertThat(headerValues).hasSize(2); + + List cookie0 = splitCookie(headerValues.get(0)); + assertThat(cookie0.remove("SID=31d4d96e407aad42")).as("SID").isTrue(); + assertThat(cookie0.stream().map(String::toLowerCase)) + .contains("partitioned"); + } + @ParameterizedHttpServerTest public void cookiesWithSameNameTest(HttpServer httpServer) throws Exception { assumeFalse(httpServer instanceof UndertowHttpServer, "Bug in Undertow in Cookies with same name handling"); @@ -116,7 +135,7 @@ public Mono handle(ServerHttpRequest request, ServerHttpResponse response) this.requestCookies.size(); // Cause lazy loading response.getCookies().add("SID", ResponseCookie.from("SID", "31d4d96e407aad42") - .path("/").secure(true).httpOnly(true).build()); + .path("/").secure(true).httpOnly(true).partitioned(true).build()); response.getCookies().add("lang", ResponseCookie.from("lang", "en-US") .domain("example.com").path("/").build()); diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilderTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilderTests.java new file mode 100644 index 000000000000..f7621a5268b1 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilderTests.java @@ -0,0 +1,117 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.http.server.reactive; + +import java.util.Locale; +import java.util.stream.Stream; + +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.ReadOnlyHttpHeaders; +import io.undertow.util.HeaderMap; +import org.apache.tomcat.util.http.MimeHeaders; +import org.eclipse.jetty.http.HttpFields; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.BDDMockito; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.support.JettyHeadersAdapter; +import org.springframework.http.support.Netty4HeadersAdapter; +import org.springframework.http.support.Netty5HeadersAdapter; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedCaseInsensitiveMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatRuntimeException; +import static org.junit.jupiter.params.provider.Arguments.argumentSet; +import static org.mockito.BDDMockito.when; + +class DefaultServerHttpRequestBuilderTests { + + @ParameterizedTest + @MethodSource("headers") + void containerImmutableHeadersAreCopied(MultiValueMap headerMap, boolean isMutableMap) { + HttpHeaders originalHeaders = new HttpHeaders(headerMap); + ServerHttpRequest mockRequest = createMockRequest(originalHeaders); + final DefaultServerHttpRequestBuilder builder = new DefaultServerHttpRequestBuilder(mockRequest); + + //perform mutations on the map adapter of the container's headers if possible + if (isMutableMap) { + headerMap.set("CaseInsensitive", "original"); + assertThat(originalHeaders.getFirst("caseinsensitive")) + .as("original mutated") + .isEqualTo("original"); + } + else { + assertThatRuntimeException().isThrownBy(() -> headerMap.set("CaseInsensitive", "original")); + assertThat(originalHeaders.getFirst("caseinsensitive")) + .as("original not mutable") + .isEqualTo("unmodified"); + } + + // Mutating the headers in the build. Note directly mutating via + // .build().getHeaders() isn't applicable since/ headers are made + // read-only by build() + ServerHttpRequest req = builder + .header("CaseInsensitive", "modified") + .header("Additional", "header") + .build(); + + assertThat(req.getHeaders().getFirst("CaseInsensitive")) + .as("copy mutated") + .isEqualTo("modified"); + assertThat(req.getHeaders().getFirst("caseinsensitive")) + .as("copy case-insensitive") + .isEqualTo("modified"); + assertThat(req.getHeaders().getFirst("additional")) + .as("copy has additional header") + .isEqualTo("header"); + } + + private ServerHttpRequest createMockRequest(HttpHeaders originalHeaders) { + //we can't use only use a MockServerHttpRequest because it uses a ReadOnlyHttpHeaders internally + ServerHttpRequest mock = BDDMockito.spy(MockServerHttpRequest.get("/example").build()); + when(mock.getHeaders()).thenReturn(originalHeaders); + + return mock; + } + + static Arguments initHeader(String description, MultiValueMap headerMap) { + headerMap.add("CaseInsensitive", "unmodified"); + return argumentSet(description, headerMap, true); + } + + static Stream headers() { + return Stream.of( + initHeader("Map", CollectionUtils.toMultiValueMap(new LinkedCaseInsensitiveMap<>(8, Locale.ENGLISH))), + initHeader("Netty", new Netty4HeadersAdapter(new DefaultHttpHeaders())), + initHeader("Netty5", new Netty5HeadersAdapter(io.netty5.handler.codec.http.headers.HttpHeaders.newHeaders())), + initHeader("Tomcat", new TomcatHeadersAdapter(new MimeHeaders())), + initHeader("Undertow", new UndertowHeadersAdapter(new HeaderMap())), + initHeader("Jetty", new JettyHeadersAdapter(HttpFields.build())), + //immutable versions of some headers + argumentSet("Netty immutable", new Netty4HeadersAdapter(new ReadOnlyHttpHeaders(false, + "CaseInsensitive", "unmodified")), false), + argumentSet("Jetty immutable", new JettyHeadersAdapter(HttpFields.build() + .add("CaseInsensitive", "unmodified").asImmutable()), false) + ); + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/ErrorHandlerIntegrationTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/ErrorHandlerIntegrationTests.java index eeb1bda99a20..145d6a8cedc8 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/ErrorHandlerIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/ErrorHandlerIntegrationTests.java @@ -27,6 +27,7 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.testfixture.http.server.reactive.bootstrap.AbstractHttpHandlerIntegrationTests; import org.springframework.web.testfixture.http.server.reactive.bootstrap.HttpServer; +import org.springframework.web.testfixture.http.server.reactive.bootstrap.JettyCoreHttpServer; import org.springframework.web.testfixture.http.server.reactive.bootstrap.JettyHttpServer; import static org.assertj.core.api.Assertions.assertThat; @@ -87,8 +88,8 @@ void emptyPathSegments(HttpServer httpServer) throws Exception { // but an application can apply CompactPathRule via RewriteHandler: // https://www.eclipse.org/jetty/documentation/jetty-11/programming_guide.php - HttpStatus expectedStatus = - (httpServer instanceof JettyHttpServer ? HttpStatus.BAD_REQUEST : HttpStatus.OK); + HttpStatus expectedStatus = (httpServer instanceof JettyHttpServer || httpServer instanceof JettyCoreHttpServer + ? HttpStatus.BAD_REQUEST : HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(expectedStatus); } diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/HeadersAdaptersTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/HeadersAdaptersTests.java index fd7825564475..823121e781d9 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/HeadersAdaptersTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/HeadersAdaptersTests.java @@ -49,8 +49,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Named.named; -import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.junit.jupiter.params.provider.Arguments.argumentSet; /** * Tests for {@code HeadersAdapters} {@code MultiValueMap} implementations. @@ -262,20 +261,20 @@ static T withHeaders(T nativeHeader, Function> @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) - @ParameterizedTest(name = "[{index}] {0}") + @ParameterizedTest @MethodSource("headers") @interface ParameterizedHeadersTest { } static Stream headers() { return Stream.of( - arguments(named("Map", CollectionUtils.toMultiValueMap(new LinkedCaseInsensitiveMap<>(8, Locale.ENGLISH)))), - arguments(named("Netty", new Netty4HeadersAdapter(new DefaultHttpHeaders()))), - arguments(named("Netty5", new Netty5HeadersAdapter(io.netty5.handler.codec.http.headers.HttpHeaders.newHeaders()))), - arguments(named("Tomcat", new TomcatHeadersAdapter(new MimeHeaders()))), - arguments(named("Undertow", new UndertowHeadersAdapter(new HeaderMap()))), - arguments(named("Jetty", new JettyHeadersAdapter(HttpFields.build()))), - arguments(named("HttpComponents", new HttpComponentsHeadersAdapter(new HttpGet("https://example.com")))) + argumentSet("Map", CollectionUtils.toMultiValueMap(new LinkedCaseInsensitiveMap<>(8, Locale.ENGLISH))), + argumentSet("Netty", new Netty4HeadersAdapter(new DefaultHttpHeaders())), + argumentSet("Netty5", new Netty5HeadersAdapter(io.netty5.handler.codec.http.headers.HttpHeaders.newHeaders())), + argumentSet("Tomcat", new TomcatHeadersAdapter(new MimeHeaders())), + argumentSet("Undertow", new UndertowHeadersAdapter(new HeaderMap())), + argumentSet("Jetty", new JettyHeadersAdapter(HttpFields.build())), + argumentSet("HttpComponents", new HttpComponentsHeadersAdapter(new HttpGet("https://example.com"))) ); } @@ -288,16 +287,16 @@ static Stream headers() { static Stream nativeHeadersWithCasedEntries() { return Stream.of( - arguments(named("Netty", new Netty4HeadersAdapter(withHeaders(new DefaultHttpHeaders(), h -> h::add)))), - arguments(named("Netty5", new Netty5HeadersAdapter(withHeaders(io.netty5.handler.codec.http.headers.HttpHeaders.newHeaders(), - h -> h::add)))), - arguments(named("Tomcat", new TomcatHeadersAdapter(withHeaders(new MimeHeaders(), - h -> (k, v) -> h.addValue(k).setString(v))))), - arguments(named("Undertow", new UndertowHeadersAdapter(withHeaders(new HeaderMap(), - h -> (k, v) -> h.add(HttpString.tryFromString(k), v))))), - arguments(named("Jetty", new JettyHeadersAdapter(withHeaders(HttpFields.build(), h -> h::add)))), - arguments(named("HttpComponents", new HttpComponentsHeadersAdapter(withHeaders(new HttpGet("https://example.com"), - h -> h::addHeader)))) + argumentSet("Netty", new Netty4HeadersAdapter(withHeaders(new DefaultHttpHeaders(), h -> h::add))), + argumentSet("Netty5", new Netty5HeadersAdapter(withHeaders(io.netty5.handler.codec.http.headers.HttpHeaders.newHeaders(), + h -> h::add))), + argumentSet("Tomcat", new TomcatHeadersAdapter(withHeaders(new MimeHeaders(), + h -> (k, v) -> h.addValue(k).setString(v)))), + argumentSet("Undertow", new UndertowHeadersAdapter(withHeaders(new HeaderMap(), + h -> (k, v) -> h.add(HttpString.tryFromString(k), v)))), + argumentSet("Jetty", new JettyHeadersAdapter(withHeaders(HttpFields.build(), h -> h::add))), + argumentSet("HttpComponents", new HttpComponentsHeadersAdapter(withHeaders(new HttpGet("https://example.com"), + h -> h::addHeader))) ); } diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/ReactorUriHelperTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/ReactorUriHelperTests.java index 3f829808a68c..f34ab6b1f49c 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/ReactorUriHelperTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/ReactorUriHelperTests.java @@ -20,6 +20,8 @@ import java.net.URISyntaxException; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import reactor.netty.http.server.HttpServerRequest; import static org.assertj.core.api.Assertions.assertThat; @@ -27,6 +29,8 @@ import static org.mockito.Mockito.mock; /** + * Unit tests for {@link ReactorUriHelper}. + * * @author Arjen Poutsma */ class ReactorUriHelperTests { @@ -49,4 +53,28 @@ void hostnameWithZoneId() throws URISyntaxException { } + @ParameterizedTest(name = "{displayName}({arguments})") + @CsvSource(delimiter='|', value = { + "/prefix | /prefix/", + "/prefix1/prefix2 | /prefix1/prefix2/", + " | /", + "'' | /", + }) + void forwardedPrefix(String forwardedPrefixHeader, String expectedPath) throws URISyntaxException { + HttpServerRequest nettyRequest = mock(); + + given(nettyRequest.scheme()).willReturn("https"); + given(nettyRequest.hostName()).willReturn("localhost"); + given(nettyRequest.hostPort()).willReturn(443); + given(nettyRequest.uri()).willReturn("/"); + given(nettyRequest.forwardedPrefix()).willReturn(forwardedPrefixHeader); + + URI uri = ReactorUriHelper.createUri(nettyRequest); + assertThat(uri).hasScheme("https") + .hasHost("localhost") + .hasPort(-1) + .hasPath(expectedPath) + .hasToString("https://localhost" + expectedPath); + } + } 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 73d7559e6475..ddf440079332 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 @@ -198,7 +198,7 @@ void mutateWithExistingContextPath() throws Exception { void mutateContextPathWithoutUpdatingPathShouldFail() throws Exception { ServerHttpRequest request = createRequest("/context/path", "/context"); - assertThatThrownBy(() -> request.mutate().contextPath("/fail").build()) + assertThatThrownBy(() -> request.mutate().contextPath("/fail").build().getPath()) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Invalid contextPath '/fail': must match the start of requestPath: '/context/path'"); } diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/ZeroCopyIntegrationTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/ZeroCopyIntegrationTests.java index 587825b0b066..eb243953e085 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/ZeroCopyIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/ZeroCopyIntegrationTests.java @@ -30,6 +30,7 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.testfixture.http.server.reactive.bootstrap.AbstractHttpHandlerIntegrationTests; import org.springframework.web.testfixture.http.server.reactive.bootstrap.HttpServer; +import org.springframework.web.testfixture.http.server.reactive.bootstrap.JettyCoreHttpServer; import org.springframework.web.testfixture.http.server.reactive.bootstrap.ReactorHttpServer; import org.springframework.web.testfixture.http.server.reactive.bootstrap.UndertowHttpServer; @@ -54,8 +55,8 @@ protected HttpHandler createHttpHandler() { @ParameterizedHttpServerTest void zeroCopy(HttpServer httpServer) throws Exception { - assumeTrue(httpServer instanceof ReactorHttpServer || httpServer instanceof UndertowHttpServer, - "Zero-copy does not support Servlet"); + assumeTrue(httpServer instanceof ReactorHttpServer || httpServer instanceof UndertowHttpServer + || httpServer instanceof JettyCoreHttpServer, "Zero-copy does not support Servlet"); startServer(httpServer); diff --git a/spring-web/src/test/java/org/springframework/protobuf/Msg.java b/spring-web/src/test/java/org/springframework/protobuf/Msg.java index 9a278ee9019f..e47b4738e7e7 100644 --- a/spring-web/src/test/java/org/springframework/protobuf/Msg.java +++ b/spring-web/src/test/java/org/springframework/protobuf/Msg.java @@ -1,5 +1,7 @@ // Generated by the protocol buffer compiler. DO NOT EDIT! +// NO CHECKED-IN PROTOBUF GENCODE // source: sample.proto +// Protobuf Java Version: 4.27.0 package org.springframework.protobuf; @@ -7,47 +9,49 @@ * Protobuf type {@code Msg} */ public final class Msg extends - com.google.protobuf.GeneratedMessageV3 implements + com.google.protobuf.GeneratedMessage implements // @@protoc_insertion_point(message_implements:Msg) MsgOrBuilder { private static final long serialVersionUID = 0L; + static { + com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion( + com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, + /* major= */ 4, + /* minor= */ 27, + /* patch= */ 0, + /* suffix= */ "", + Msg.class.getName()); + } // Use Msg.newBuilder() to construct. - private Msg(com.google.protobuf.GeneratedMessageV3.Builder builder) { + private Msg(com.google.protobuf.GeneratedMessage.Builder builder) { super(builder); } private Msg() { foo_ = ""; } - @Override - @SuppressWarnings({"unused"}) - protected Object newInstance( - UnusedPrivateParameter unused) { - return new Msg(); - } - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return OuterSample.internal_static_Msg_descriptor; + return org.springframework.protobuf.OuterSample.internal_static_Msg_descriptor; } - @Override - protected FieldAccessorTable + @java.lang.Override + protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return OuterSample.internal_static_Msg_fieldAccessorTable + return org.springframework.protobuf.OuterSample.internal_static_Msg_fieldAccessorTable .ensureFieldAccessorsInitialized( - Msg.class, Builder.class); + org.springframework.protobuf.Msg.class, org.springframework.protobuf.Msg.Builder.class); } private int bitField0_; public static final int FOO_FIELD_NUMBER = 1; @SuppressWarnings("serial") - private volatile Object foo_ = ""; + private volatile java.lang.Object foo_ = ""; /** * optional string foo = 1; * @return Whether the foo field is set. */ - @Override + @java.lang.Override public boolean hasFoo() { return ((bitField0_ & 0x00000001) != 0); } @@ -55,15 +59,15 @@ public boolean hasFoo() { * optional string foo = 1; * @return The foo. */ - @Override - public String getFoo() { - Object ref = foo_; - if (ref instanceof String) { - return (String) ref; + @java.lang.Override + public java.lang.String getFoo() { + java.lang.Object ref = foo_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; } else { com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; - String s = bs.toStringUtf8(); + java.lang.String s = bs.toStringUtf8(); if (bs.isValidUtf8()) { foo_ = s; } @@ -74,14 +78,14 @@ public String getFoo() { * optional string foo = 1; * @return The bytes for foo. */ - @Override + @java.lang.Override public com.google.protobuf.ByteString getFooBytes() { - Object ref = foo_; - if (ref instanceof String) { + java.lang.Object ref = foo_; + if (ref instanceof java.lang.String) { com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( - (String) ref); + (java.lang.String) ref); foo_ = b; return b; } else { @@ -90,12 +94,12 @@ public String getFoo() { } public static final int BLAH_FIELD_NUMBER = 2; - private SecondMsg blah_; + private org.springframework.protobuf.SecondMsg blah_; /** * optional .SecondMsg blah = 2; * @return Whether the blah field is set. */ - @Override + @java.lang.Override public boolean hasBlah() { return ((bitField0_ & 0x00000002) != 0); } @@ -103,20 +107,20 @@ public boolean hasBlah() { * optional .SecondMsg blah = 2; * @return The blah. */ - @Override - public SecondMsg getBlah() { - return blah_ == null ? SecondMsg.getDefaultInstance() : blah_; + @java.lang.Override + public org.springframework.protobuf.SecondMsg getBlah() { + return blah_ == null ? org.springframework.protobuf.SecondMsg.getDefaultInstance() : blah_; } /** * optional .SecondMsg blah = 2; */ - @Override - public SecondMsgOrBuilder getBlahOrBuilder() { - return blah_ == null ? SecondMsg.getDefaultInstance() : blah_; + @java.lang.Override + public org.springframework.protobuf.SecondMsgOrBuilder getBlahOrBuilder() { + return blah_ == null ? org.springframework.protobuf.SecondMsg.getDefaultInstance() : blah_; } private byte memoizedIsInitialized = -1; - @Override + @java.lang.Override public final boolean isInitialized() { byte isInitialized = memoizedIsInitialized; if (isInitialized == 1) return true; @@ -126,11 +130,11 @@ public final boolean isInitialized() { return true; } - @Override + @java.lang.Override public void writeTo(com.google.protobuf.CodedOutputStream output) throws java.io.IOException { if (((bitField0_ & 0x00000001) != 0)) { - com.google.protobuf.GeneratedMessageV3.writeString(output, 1, foo_); + com.google.protobuf.GeneratedMessage.writeString(output, 1, foo_); } if (((bitField0_ & 0x00000002) != 0)) { output.writeMessage(2, getBlah()); @@ -138,14 +142,14 @@ public void writeTo(com.google.protobuf.CodedOutputStream output) getUnknownFields().writeTo(output); } - @Override + @java.lang.Override public int getSerializedSize() { int size = memoizedSize; if (size != -1) return size; size = 0; if (((bitField0_ & 0x00000001) != 0)) { - size += com.google.protobuf.GeneratedMessageV3.computeStringSize(1, foo_); + size += com.google.protobuf.GeneratedMessage.computeStringSize(1, foo_); } if (((bitField0_ & 0x00000002) != 0)) { size += com.google.protobuf.CodedOutputStream @@ -156,15 +160,15 @@ public int getSerializedSize() { return size; } - @Override - public boolean equals(final Object obj) { + @java.lang.Override + public boolean equals(final java.lang.Object obj) { if (obj == this) { return true; } - if (!(obj instanceof Msg)) { + if (!(obj instanceof org.springframework.protobuf.Msg)) { return super.equals(obj); } - Msg other = (Msg) obj; + org.springframework.protobuf.Msg other = (org.springframework.protobuf.Msg) obj; if (hasFoo() != other.hasFoo()) return false; if (hasFoo()) { @@ -180,7 +184,7 @@ public boolean equals(final Object obj) { return true; } - @Override + @java.lang.Override public int hashCode() { if (memoizedHashCode != 0) { return memoizedHashCode; @@ -200,95 +204,95 @@ public int hashCode() { return hash; } - public static Msg parseFrom( + public static org.springframework.protobuf.Msg parseFrom( java.nio.ByteBuffer data) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data); } - public static Msg parseFrom( + public static org.springframework.protobuf.Msg parseFrom( java.nio.ByteBuffer data, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data, extensionRegistry); } - public static Msg parseFrom( + public static org.springframework.protobuf.Msg parseFrom( com.google.protobuf.ByteString data) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data); } - public static Msg parseFrom( + public static org.springframework.protobuf.Msg parseFrom( com.google.protobuf.ByteString data, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data, extensionRegistry); } - public static Msg parseFrom(byte[] data) + public static org.springframework.protobuf.Msg parseFrom(byte[] data) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data); } - public static Msg parseFrom( + public static org.springframework.protobuf.Msg parseFrom( byte[] data, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data, extensionRegistry); } - public static Msg parseFrom(java.io.InputStream input) + public static org.springframework.protobuf.Msg parseFrom(java.io.InputStream input) throws java.io.IOException { - return com.google.protobuf.GeneratedMessageV3 + return com.google.protobuf.GeneratedMessage .parseWithIOException(PARSER, input); } - public static Msg parseFrom( + public static org.springframework.protobuf.Msg parseFrom( java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - return com.google.protobuf.GeneratedMessageV3 + return com.google.protobuf.GeneratedMessage .parseWithIOException(PARSER, input, extensionRegistry); } - public static Msg parseDelimitedFrom(java.io.InputStream input) + public static org.springframework.protobuf.Msg parseDelimitedFrom(java.io.InputStream input) throws java.io.IOException { - return com.google.protobuf.GeneratedMessageV3 + return com.google.protobuf.GeneratedMessage .parseDelimitedWithIOException(PARSER, input); } - public static Msg parseDelimitedFrom( + public static org.springframework.protobuf.Msg parseDelimitedFrom( java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - return com.google.protobuf.GeneratedMessageV3 + return com.google.protobuf.GeneratedMessage .parseDelimitedWithIOException(PARSER, input, extensionRegistry); } - public static Msg parseFrom( + public static org.springframework.protobuf.Msg parseFrom( com.google.protobuf.CodedInputStream input) throws java.io.IOException { - return com.google.protobuf.GeneratedMessageV3 + return com.google.protobuf.GeneratedMessage .parseWithIOException(PARSER, input); } - public static Msg parseFrom( + public static org.springframework.protobuf.Msg parseFrom( com.google.protobuf.CodedInputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - return com.google.protobuf.GeneratedMessageV3 + return com.google.protobuf.GeneratedMessage .parseWithIOException(PARSER, input, extensionRegistry); } - @Override + @java.lang.Override public Builder newBuilderForType() { return newBuilder(); } public static Builder newBuilder() { return DEFAULT_INSTANCE.toBuilder(); } - public static Builder newBuilder(Msg prototype) { + public static Builder newBuilder(org.springframework.protobuf.Msg prototype) { return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); } - @Override + @java.lang.Override public Builder toBuilder() { return this == DEFAULT_INSTANCE ? new Builder() : new Builder().mergeFrom(this); } - @Override + @java.lang.Override protected Builder newBuilderForType( - BuilderParent parent) { + com.google.protobuf.GeneratedMessage.BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -296,20 +300,20 @@ protected Builder newBuilderForType( * Protobuf type {@code Msg} */ public static final class Builder extends - com.google.protobuf.GeneratedMessageV3.Builder implements + com.google.protobuf.GeneratedMessage.Builder implements // @@protoc_insertion_point(builder_implements:Msg) - MsgOrBuilder { + org.springframework.protobuf.MsgOrBuilder { public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return OuterSample.internal_static_Msg_descriptor; + return org.springframework.protobuf.OuterSample.internal_static_Msg_descriptor; } - @Override - protected FieldAccessorTable + @java.lang.Override + protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return OuterSample.internal_static_Msg_fieldAccessorTable + return org.springframework.protobuf.OuterSample.internal_static_Msg_fieldAccessorTable .ensureFieldAccessorsInitialized( - Msg.class, Builder.class); + org.springframework.protobuf.Msg.class, org.springframework.protobuf.Msg.Builder.class); } // Construct using org.springframework.protobuf.Msg.newBuilder() @@ -318,17 +322,17 @@ private Builder() { } private Builder( - BuilderParent parent) { + com.google.protobuf.GeneratedMessage.BuilderParent parent) { super(parent); maybeForceBuilderInitialization(); } private void maybeForceBuilderInitialization() { - if (com.google.protobuf.GeneratedMessageV3 + if (com.google.protobuf.GeneratedMessage .alwaysUseFieldBuilders) { getBlahFieldBuilder(); } } - @Override + @java.lang.Override public Builder clear() { super.clear(); bitField0_ = 0; @@ -341,35 +345,35 @@ public Builder clear() { return this; } - @Override + @java.lang.Override public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() { - return OuterSample.internal_static_Msg_descriptor; + return org.springframework.protobuf.OuterSample.internal_static_Msg_descriptor; } - @Override - public Msg getDefaultInstanceForType() { - return Msg.getDefaultInstance(); + @java.lang.Override + public org.springframework.protobuf.Msg getDefaultInstanceForType() { + return org.springframework.protobuf.Msg.getDefaultInstance(); } - @Override - public Msg build() { - Msg result = buildPartial(); + @java.lang.Override + public org.springframework.protobuf.Msg build() { + org.springframework.protobuf.Msg result = buildPartial(); if (!result.isInitialized()) { throw newUninitializedMessageException(result); } return result; } - @Override - public Msg buildPartial() { - Msg result = new Msg(this); + @java.lang.Override + public org.springframework.protobuf.Msg buildPartial() { + org.springframework.protobuf.Msg result = new org.springframework.protobuf.Msg(this); if (bitField0_ != 0) { buildPartial0(result); } onBuilt(); return result; } - private void buildPartial0(Msg result) { + private void buildPartial0(org.springframework.protobuf.Msg result) { int from_bitField0_ = bitField0_; int to_bitField0_ = 0; if (((from_bitField0_ & 0x00000001) != 0)) { @@ -385,50 +389,18 @@ private void buildPartial0(Msg result) { result.bitField0_ |= to_bitField0_; } - @Override - public Builder clone() { - return super.clone(); - } - @Override - public Builder setField( - com.google.protobuf.Descriptors.FieldDescriptor field, - Object value) { - return super.setField(field, value); - } - @Override - public Builder clearField( - com.google.protobuf.Descriptors.FieldDescriptor field) { - return super.clearField(field); - } - @Override - public Builder clearOneof( - com.google.protobuf.Descriptors.OneofDescriptor oneof) { - return super.clearOneof(oneof); - } - @Override - public Builder setRepeatedField( - com.google.protobuf.Descriptors.FieldDescriptor field, - int index, Object value) { - return super.setRepeatedField(field, index, value); - } - @Override - public Builder addRepeatedField( - com.google.protobuf.Descriptors.FieldDescriptor field, - Object value) { - return super.addRepeatedField(field, value); - } - @Override + @java.lang.Override public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof Msg) { - return mergeFrom((Msg)other); + if (other instanceof org.springframework.protobuf.Msg) { + return mergeFrom((org.springframework.protobuf.Msg)other); } else { super.mergeFrom(other); return this; } } - public Builder mergeFrom(Msg other) { - if (other == Msg.getDefaultInstance()) return this; + public Builder mergeFrom(org.springframework.protobuf.Msg other) { + if (other == org.springframework.protobuf.Msg.getDefaultInstance()) return this; if (other.hasFoo()) { foo_ = other.foo_; bitField0_ |= 0x00000001; @@ -442,18 +414,18 @@ public Builder mergeFrom(Msg other) { return this; } - @Override + @java.lang.Override public final boolean isInitialized() { return true; } - @Override + @java.lang.Override public Builder mergeFrom( com.google.protobuf.CodedInputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { if (extensionRegistry == null) { - throw new NullPointerException(); + throw new java.lang.NullPointerException(); } try { boolean done = false; @@ -492,7 +464,7 @@ public Builder mergeFrom( } private int bitField0_; - private Object foo_ = ""; + private java.lang.Object foo_ = ""; /** * optional string foo = 1; * @return Whether the foo field is set. @@ -504,18 +476,18 @@ public boolean hasFoo() { * optional string foo = 1; * @return The foo. */ - public String getFoo() { - Object ref = foo_; - if (!(ref instanceof String)) { + public java.lang.String getFoo() { + java.lang.Object ref = foo_; + if (!(ref instanceof java.lang.String)) { com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; - String s = bs.toStringUtf8(); + java.lang.String s = bs.toStringUtf8(); if (bs.isValidUtf8()) { foo_ = s; } return s; } else { - return (String) ref; + return (java.lang.String) ref; } } /** @@ -524,11 +496,11 @@ public String getFoo() { */ public com.google.protobuf.ByteString getFooBytes() { - Object ref = foo_; + java.lang.Object ref = foo_; if (ref instanceof String) { com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( - (String) ref); + (java.lang.String) ref); foo_ = b; return b; } else { @@ -541,7 +513,7 @@ public String getFoo() { * @return This builder for chaining. */ public Builder setFoo( - String value) { + java.lang.String value) { if (value == null) { throw new NullPointerException(); } foo_ = value; bitField0_ |= 0x00000001; @@ -572,9 +544,9 @@ public Builder setFooBytes( return this; } - private SecondMsg blah_; - private com.google.protobuf.SingleFieldBuilderV3< - SecondMsg, SecondMsg.Builder, SecondMsgOrBuilder> blahBuilder_; + private org.springframework.protobuf.SecondMsg blah_; + private com.google.protobuf.SingleFieldBuilder< + org.springframework.protobuf.SecondMsg, org.springframework.protobuf.SecondMsg.Builder, org.springframework.protobuf.SecondMsgOrBuilder> blahBuilder_; /** * optional .SecondMsg blah = 2; * @return Whether the blah field is set. @@ -586,9 +558,9 @@ public boolean hasBlah() { * optional .SecondMsg blah = 2; * @return The blah. */ - public SecondMsg getBlah() { + public org.springframework.protobuf.SecondMsg getBlah() { if (blahBuilder_ == null) { - return blah_ == null ? SecondMsg.getDefaultInstance() : blah_; + return blah_ == null ? org.springframework.protobuf.SecondMsg.getDefaultInstance() : blah_; } else { return blahBuilder_.getMessage(); } @@ -596,7 +568,7 @@ public SecondMsg getBlah() { /** * optional .SecondMsg blah = 2; */ - public Builder setBlah(SecondMsg value) { + public Builder setBlah(org.springframework.protobuf.SecondMsg value) { if (blahBuilder_ == null) { if (value == null) { throw new NullPointerException(); @@ -613,7 +585,7 @@ public Builder setBlah(SecondMsg value) { * optional .SecondMsg blah = 2; */ public Builder setBlah( - SecondMsg.Builder builderForValue) { + org.springframework.protobuf.SecondMsg.Builder builderForValue) { if (blahBuilder_ == null) { blah_ = builderForValue.build(); } else { @@ -626,11 +598,11 @@ public Builder setBlah( /** * optional .SecondMsg blah = 2; */ - public Builder mergeBlah(SecondMsg value) { + public Builder mergeBlah(org.springframework.protobuf.SecondMsg value) { if (blahBuilder_ == null) { if (((bitField0_ & 0x00000002) != 0) && blah_ != null && - blah_ != SecondMsg.getDefaultInstance()) { + blah_ != org.springframework.protobuf.SecondMsg.getDefaultInstance()) { getBlahBuilder().mergeFrom(value); } else { blah_ = value; @@ -660,7 +632,7 @@ public Builder clearBlah() { /** * optional .SecondMsg blah = 2; */ - public SecondMsg.Builder getBlahBuilder() { + public org.springframework.protobuf.SecondMsg.Builder getBlahBuilder() { bitField0_ |= 0x00000002; onChanged(); return getBlahFieldBuilder().getBuilder(); @@ -668,23 +640,23 @@ public SecondMsg.Builder getBlahBuilder() { /** * optional .SecondMsg blah = 2; */ - public SecondMsgOrBuilder getBlahOrBuilder() { + public org.springframework.protobuf.SecondMsgOrBuilder getBlahOrBuilder() { if (blahBuilder_ != null) { return blahBuilder_.getMessageOrBuilder(); } else { return blah_ == null ? - SecondMsg.getDefaultInstance() : blah_; + org.springframework.protobuf.SecondMsg.getDefaultInstance() : blah_; } } /** * optional .SecondMsg blah = 2; */ - private com.google.protobuf.SingleFieldBuilderV3< - SecondMsg, SecondMsg.Builder, SecondMsgOrBuilder> + private com.google.protobuf.SingleFieldBuilder< + org.springframework.protobuf.SecondMsg, org.springframework.protobuf.SecondMsg.Builder, org.springframework.protobuf.SecondMsgOrBuilder> getBlahFieldBuilder() { if (blahBuilder_ == null) { - blahBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< - SecondMsg, SecondMsg.Builder, SecondMsgOrBuilder>( + blahBuilder_ = new com.google.protobuf.SingleFieldBuilder< + org.springframework.protobuf.SecondMsg, org.springframework.protobuf.SecondMsg.Builder, org.springframework.protobuf.SecondMsgOrBuilder>( getBlah(), getParentForChildren(), isClean()); @@ -692,35 +664,23 @@ public SecondMsgOrBuilder getBlahOrBuilder() { } return blahBuilder_; } - @Override - public final Builder setUnknownFields( - final com.google.protobuf.UnknownFieldSet unknownFields) { - return super.setUnknownFields(unknownFields); - } - - @Override - public final Builder mergeUnknownFields( - final com.google.protobuf.UnknownFieldSet unknownFields) { - return super.mergeUnknownFields(unknownFields); - } - // @@protoc_insertion_point(builder_scope:Msg) } // @@protoc_insertion_point(class_scope:Msg) - private static final Msg DEFAULT_INSTANCE; + private static final org.springframework.protobuf.Msg DEFAULT_INSTANCE; static { - DEFAULT_INSTANCE = new Msg(); + DEFAULT_INSTANCE = new org.springframework.protobuf.Msg(); } - public static Msg getDefaultInstance() { + public static org.springframework.protobuf.Msg getDefaultInstance() { return DEFAULT_INSTANCE; } - @Deprecated public static final com.google.protobuf.Parser + private static final com.google.protobuf.Parser PARSER = new com.google.protobuf.AbstractParser() { - @Override + @java.lang.Override public Msg parsePartialFrom( com.google.protobuf.CodedInputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) @@ -744,13 +704,13 @@ public static com.google.protobuf.Parser parser() { return PARSER; } - @Override + @java.lang.Override public com.google.protobuf.Parser getParserForType() { return PARSER; } - @Override - public Msg getDefaultInstanceForType() { + @java.lang.Override + public org.springframework.protobuf.Msg getDefaultInstanceForType() { return DEFAULT_INSTANCE; } diff --git a/spring-web/src/test/java/org/springframework/protobuf/MsgOrBuilder.java b/spring-web/src/test/java/org/springframework/protobuf/MsgOrBuilder.java index 866b7cfde73b..527491727758 100644 --- a/spring-web/src/test/java/org/springframework/protobuf/MsgOrBuilder.java +++ b/spring-web/src/test/java/org/springframework/protobuf/MsgOrBuilder.java @@ -1,5 +1,7 @@ // Generated by the protocol buffer compiler. DO NOT EDIT! +// NO CHECKED-IN PROTOBUF GENCODE // source: sample.proto +// Protobuf Java Version: 4.27.0 package org.springframework.protobuf; @@ -16,7 +18,7 @@ public interface MsgOrBuilder extends * optional string foo = 1; * @return The foo. */ - String getFoo(); + java.lang.String getFoo(); /** * optional string foo = 1; * @return The bytes for foo. @@ -33,9 +35,9 @@ public interface MsgOrBuilder extends * optional .SecondMsg blah = 2; * @return The blah. */ - SecondMsg getBlah(); + org.springframework.protobuf.SecondMsg getBlah(); /** * optional .SecondMsg blah = 2; */ - SecondMsgOrBuilder getBlahOrBuilder(); + org.springframework.protobuf.SecondMsgOrBuilder getBlahOrBuilder(); } diff --git a/spring-web/src/test/java/org/springframework/protobuf/OuterSample.java b/spring-web/src/test/java/org/springframework/protobuf/OuterSample.java index dbfb1b47323a..26267b08e6c9 100644 --- a/spring-web/src/test/java/org/springframework/protobuf/OuterSample.java +++ b/spring-web/src/test/java/org/springframework/protobuf/OuterSample.java @@ -1,26 +1,21 @@ -/* - * Copyright 2002-2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.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. - */ - // Generated by the protocol buffer compiler. DO NOT EDIT! +// NO CHECKED-IN PROTOBUF GENCODE // source: sample.proto +// Protobuf Java Version: 4.27.0 package org.springframework.protobuf; public final class OuterSample { private OuterSample() {} + static { + com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion( + com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, + /* major= */ 4, + /* minor= */ 27, + /* patch= */ 0, + /* suffix= */ "", + OuterSample.class.getName()); + } public static void registerAllExtensions( com.google.protobuf.ExtensionRegistryLite registry) { } @@ -33,12 +28,12 @@ public static void registerAllExtensions( static final com.google.protobuf.Descriptors.Descriptor internal_static_Msg_descriptor; static final - com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_Msg_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_SecondMsg_descriptor; static final - com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_SecondMsg_fieldAccessorTable; public static com.google.protobuf.Descriptors.FileDescriptor @@ -48,7 +43,7 @@ public static void registerAllExtensions( private static com.google.protobuf.Descriptors.FileDescriptor descriptor; static { - String[] descriptorData = { + java.lang.String[] descriptorData = { "\n\014sample.proto\",\n\003Msg\022\013\n\003foo\030\001 \001(\t\022\030\n\004bl" + "ah\030\002 \001(\0132\n.SecondMsg\"\031\n\tSecondMsg\022\014\n\004bla" + "h\030\001 \001(\005B-\n\034org.springframework.protobufB" + @@ -61,15 +56,16 @@ public static void registerAllExtensions( internal_static_Msg_descriptor = getDescriptor().getMessageTypes().get(0); internal_static_Msg_fieldAccessorTable = new - com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( + com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_Msg_descriptor, - new String[] { "Foo", "Blah", }); + new java.lang.String[] { "Foo", "Blah", }); internal_static_SecondMsg_descriptor = getDescriptor().getMessageTypes().get(1); internal_static_SecondMsg_fieldAccessorTable = new - com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( + com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_SecondMsg_descriptor, - new String[] { "Blah", }); + new java.lang.String[] { "Blah", }); + descriptor.resolveAllFeaturesImmutable(); } // @@protoc_insertion_point(outer_class_scope) diff --git a/spring-web/src/test/java/org/springframework/protobuf/SecondMsg.java b/spring-web/src/test/java/org/springframework/protobuf/SecondMsg.java index 5b6ab1092dd8..7f75718f23b9 100644 --- a/spring-web/src/test/java/org/springframework/protobuf/SecondMsg.java +++ b/spring-web/src/test/java/org/springframework/protobuf/SecondMsg.java @@ -1,5 +1,7 @@ // Generated by the protocol buffer compiler. DO NOT EDIT! +// NO CHECKED-IN PROTOBUF GENCODE // source: sample.proto +// Protobuf Java Version: 4.27.0 package org.springframework.protobuf; @@ -7,35 +9,37 @@ * Protobuf type {@code SecondMsg} */ public final class SecondMsg extends - com.google.protobuf.GeneratedMessageV3 implements + com.google.protobuf.GeneratedMessage implements // @@protoc_insertion_point(message_implements:SecondMsg) SecondMsgOrBuilder { private static final long serialVersionUID = 0L; + static { + com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion( + com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, + /* major= */ 4, + /* minor= */ 27, + /* patch= */ 0, + /* suffix= */ "", + SecondMsg.class.getName()); + } // Use SecondMsg.newBuilder() to construct. - private SecondMsg(com.google.protobuf.GeneratedMessageV3.Builder builder) { + private SecondMsg(com.google.protobuf.GeneratedMessage.Builder builder) { super(builder); } private SecondMsg() { } - @Override - @SuppressWarnings({"unused"}) - protected Object newInstance( - UnusedPrivateParameter unused) { - return new SecondMsg(); - } - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return OuterSample.internal_static_SecondMsg_descriptor; + return org.springframework.protobuf.OuterSample.internal_static_SecondMsg_descriptor; } - @Override - protected FieldAccessorTable + @java.lang.Override + protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return OuterSample.internal_static_SecondMsg_fieldAccessorTable + return org.springframework.protobuf.OuterSample.internal_static_SecondMsg_fieldAccessorTable .ensureFieldAccessorsInitialized( - SecondMsg.class, Builder.class); + org.springframework.protobuf.SecondMsg.class, org.springframework.protobuf.SecondMsg.Builder.class); } private int bitField0_; @@ -45,7 +49,7 @@ protected Object newInstance( * optional int32 blah = 1; * @return Whether the blah field is set. */ - @Override + @java.lang.Override public boolean hasBlah() { return ((bitField0_ & 0x00000001) != 0); } @@ -53,13 +57,13 @@ public boolean hasBlah() { * optional int32 blah = 1; * @return The blah. */ - @Override + @java.lang.Override public int getBlah() { return blah_; } private byte memoizedIsInitialized = -1; - @Override + @java.lang.Override public final boolean isInitialized() { byte isInitialized = memoizedIsInitialized; if (isInitialized == 1) return true; @@ -69,7 +73,7 @@ public final boolean isInitialized() { return true; } - @Override + @java.lang.Override public void writeTo(com.google.protobuf.CodedOutputStream output) throws java.io.IOException { if (((bitField0_ & 0x00000001) != 0)) { @@ -78,7 +82,7 @@ public void writeTo(com.google.protobuf.CodedOutputStream output) getUnknownFields().writeTo(output); } - @Override + @java.lang.Override public int getSerializedSize() { int size = memoizedSize; if (size != -1) return size; @@ -93,15 +97,15 @@ public int getSerializedSize() { return size; } - @Override - public boolean equals(final Object obj) { + @java.lang.Override + public boolean equals(final java.lang.Object obj) { if (obj == this) { return true; } - if (!(obj instanceof SecondMsg)) { + if (!(obj instanceof org.springframework.protobuf.SecondMsg)) { return super.equals(obj); } - SecondMsg other = (SecondMsg) obj; + org.springframework.protobuf.SecondMsg other = (org.springframework.protobuf.SecondMsg) obj; if (hasBlah() != other.hasBlah()) return false; if (hasBlah()) { @@ -112,7 +116,7 @@ public boolean equals(final Object obj) { return true; } - @Override + @java.lang.Override public int hashCode() { if (memoizedHashCode != 0) { return memoizedHashCode; @@ -128,95 +132,95 @@ public int hashCode() { return hash; } - public static SecondMsg parseFrom( + public static org.springframework.protobuf.SecondMsg parseFrom( java.nio.ByteBuffer data) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data); } - public static SecondMsg parseFrom( + public static org.springframework.protobuf.SecondMsg parseFrom( java.nio.ByteBuffer data, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data, extensionRegistry); } - public static SecondMsg parseFrom( + public static org.springframework.protobuf.SecondMsg parseFrom( com.google.protobuf.ByteString data) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data); } - public static SecondMsg parseFrom( + public static org.springframework.protobuf.SecondMsg parseFrom( com.google.protobuf.ByteString data, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data, extensionRegistry); } - public static SecondMsg parseFrom(byte[] data) + public static org.springframework.protobuf.SecondMsg parseFrom(byte[] data) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data); } - public static SecondMsg parseFrom( + public static org.springframework.protobuf.SecondMsg parseFrom( byte[] data, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data, extensionRegistry); } - public static SecondMsg parseFrom(java.io.InputStream input) + public static org.springframework.protobuf.SecondMsg parseFrom(java.io.InputStream input) throws java.io.IOException { - return com.google.protobuf.GeneratedMessageV3 + return com.google.protobuf.GeneratedMessage .parseWithIOException(PARSER, input); } - public static SecondMsg parseFrom( + public static org.springframework.protobuf.SecondMsg parseFrom( java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - return com.google.protobuf.GeneratedMessageV3 + return com.google.protobuf.GeneratedMessage .parseWithIOException(PARSER, input, extensionRegistry); } - public static SecondMsg parseDelimitedFrom(java.io.InputStream input) + public static org.springframework.protobuf.SecondMsg parseDelimitedFrom(java.io.InputStream input) throws java.io.IOException { - return com.google.protobuf.GeneratedMessageV3 + return com.google.protobuf.GeneratedMessage .parseDelimitedWithIOException(PARSER, input); } - public static SecondMsg parseDelimitedFrom( + public static org.springframework.protobuf.SecondMsg parseDelimitedFrom( java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - return com.google.protobuf.GeneratedMessageV3 + return com.google.protobuf.GeneratedMessage .parseDelimitedWithIOException(PARSER, input, extensionRegistry); } - public static SecondMsg parseFrom( + public static org.springframework.protobuf.SecondMsg parseFrom( com.google.protobuf.CodedInputStream input) throws java.io.IOException { - return com.google.protobuf.GeneratedMessageV3 + return com.google.protobuf.GeneratedMessage .parseWithIOException(PARSER, input); } - public static SecondMsg parseFrom( + public static org.springframework.protobuf.SecondMsg parseFrom( com.google.protobuf.CodedInputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - return com.google.protobuf.GeneratedMessageV3 + return com.google.protobuf.GeneratedMessage .parseWithIOException(PARSER, input, extensionRegistry); } - @Override + @java.lang.Override public Builder newBuilderForType() { return newBuilder(); } public static Builder newBuilder() { return DEFAULT_INSTANCE.toBuilder(); } - public static Builder newBuilder(SecondMsg prototype) { + public static Builder newBuilder(org.springframework.protobuf.SecondMsg prototype) { return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); } - @Override + @java.lang.Override public Builder toBuilder() { return this == DEFAULT_INSTANCE ? new Builder() : new Builder().mergeFrom(this); } - @Override + @java.lang.Override protected Builder newBuilderForType( - BuilderParent parent) { + com.google.protobuf.GeneratedMessage.BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -224,20 +228,20 @@ protected Builder newBuilderForType( * Protobuf type {@code SecondMsg} */ public static final class Builder extends - com.google.protobuf.GeneratedMessageV3.Builder implements + com.google.protobuf.GeneratedMessage.Builder implements // @@protoc_insertion_point(builder_implements:SecondMsg) - SecondMsgOrBuilder { + org.springframework.protobuf.SecondMsgOrBuilder { public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return OuterSample.internal_static_SecondMsg_descriptor; + return org.springframework.protobuf.OuterSample.internal_static_SecondMsg_descriptor; } - @Override - protected FieldAccessorTable + @java.lang.Override + protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return OuterSample.internal_static_SecondMsg_fieldAccessorTable + return org.springframework.protobuf.OuterSample.internal_static_SecondMsg_fieldAccessorTable .ensureFieldAccessorsInitialized( - SecondMsg.class, Builder.class); + org.springframework.protobuf.SecondMsg.class, org.springframework.protobuf.SecondMsg.Builder.class); } // Construct using org.springframework.protobuf.SecondMsg.newBuilder() @@ -246,11 +250,11 @@ private Builder() { } private Builder( - BuilderParent parent) { + com.google.protobuf.GeneratedMessage.BuilderParent parent) { super(parent); } - @Override + @java.lang.Override public Builder clear() { super.clear(); bitField0_ = 0; @@ -258,35 +262,35 @@ public Builder clear() { return this; } - @Override + @java.lang.Override public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() { - return OuterSample.internal_static_SecondMsg_descriptor; + return org.springframework.protobuf.OuterSample.internal_static_SecondMsg_descriptor; } - @Override - public SecondMsg getDefaultInstanceForType() { - return SecondMsg.getDefaultInstance(); + @java.lang.Override + public org.springframework.protobuf.SecondMsg getDefaultInstanceForType() { + return org.springframework.protobuf.SecondMsg.getDefaultInstance(); } - @Override - public SecondMsg build() { - SecondMsg result = buildPartial(); + @java.lang.Override + public org.springframework.protobuf.SecondMsg build() { + org.springframework.protobuf.SecondMsg result = buildPartial(); if (!result.isInitialized()) { throw newUninitializedMessageException(result); } return result; } - @Override - public SecondMsg buildPartial() { - SecondMsg result = new SecondMsg(this); + @java.lang.Override + public org.springframework.protobuf.SecondMsg buildPartial() { + org.springframework.protobuf.SecondMsg result = new org.springframework.protobuf.SecondMsg(this); if (bitField0_ != 0) { buildPartial0(result); } onBuilt(); return result; } - private void buildPartial0(SecondMsg result) { + private void buildPartial0(org.springframework.protobuf.SecondMsg result) { int from_bitField0_ = bitField0_; int to_bitField0_ = 0; if (((from_bitField0_ & 0x00000001) != 0)) { @@ -296,50 +300,18 @@ private void buildPartial0(SecondMsg result) { result.bitField0_ |= to_bitField0_; } - @Override - public Builder clone() { - return super.clone(); - } - @Override - public Builder setField( - com.google.protobuf.Descriptors.FieldDescriptor field, - Object value) { - return super.setField(field, value); - } - @Override - public Builder clearField( - com.google.protobuf.Descriptors.FieldDescriptor field) { - return super.clearField(field); - } - @Override - public Builder clearOneof( - com.google.protobuf.Descriptors.OneofDescriptor oneof) { - return super.clearOneof(oneof); - } - @Override - public Builder setRepeatedField( - com.google.protobuf.Descriptors.FieldDescriptor field, - int index, Object value) { - return super.setRepeatedField(field, index, value); - } - @Override - public Builder addRepeatedField( - com.google.protobuf.Descriptors.FieldDescriptor field, - Object value) { - return super.addRepeatedField(field, value); - } - @Override + @java.lang.Override public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof SecondMsg) { - return mergeFrom((SecondMsg)other); + if (other instanceof org.springframework.protobuf.SecondMsg) { + return mergeFrom((org.springframework.protobuf.SecondMsg)other); } else { super.mergeFrom(other); return this; } } - public Builder mergeFrom(SecondMsg other) { - if (other == SecondMsg.getDefaultInstance()) return this; + public Builder mergeFrom(org.springframework.protobuf.SecondMsg other) { + if (other == org.springframework.protobuf.SecondMsg.getDefaultInstance()) return this; if (other.hasBlah()) { setBlah(other.getBlah()); } @@ -348,18 +320,18 @@ public Builder mergeFrom(SecondMsg other) { return this; } - @Override + @java.lang.Override public final boolean isInitialized() { return true; } - @Override + @java.lang.Override public Builder mergeFrom( com.google.protobuf.CodedInputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { if (extensionRegistry == null) { - throw new NullPointerException(); + throw new java.lang.NullPointerException(); } try { boolean done = false; @@ -396,7 +368,7 @@ public Builder mergeFrom( * optional int32 blah = 1; * @return Whether the blah field is set. */ - @Override + @java.lang.Override public boolean hasBlah() { return ((bitField0_ & 0x00000001) != 0); } @@ -404,7 +376,7 @@ public boolean hasBlah() { * optional int32 blah = 1; * @return The blah. */ - @Override + @java.lang.Override public int getBlah() { return blah_; } @@ -430,35 +402,23 @@ public Builder clearBlah() { onChanged(); return this; } - @Override - public final Builder setUnknownFields( - final com.google.protobuf.UnknownFieldSet unknownFields) { - return super.setUnknownFields(unknownFields); - } - - @Override - public final Builder mergeUnknownFields( - final com.google.protobuf.UnknownFieldSet unknownFields) { - return super.mergeUnknownFields(unknownFields); - } - // @@protoc_insertion_point(builder_scope:SecondMsg) } // @@protoc_insertion_point(class_scope:SecondMsg) - private static final SecondMsg DEFAULT_INSTANCE; + private static final org.springframework.protobuf.SecondMsg DEFAULT_INSTANCE; static { - DEFAULT_INSTANCE = new SecondMsg(); + DEFAULT_INSTANCE = new org.springframework.protobuf.SecondMsg(); } - public static SecondMsg getDefaultInstance() { + public static org.springframework.protobuf.SecondMsg getDefaultInstance() { return DEFAULT_INSTANCE; } - @Deprecated public static final com.google.protobuf.Parser + private static final com.google.protobuf.Parser PARSER = new com.google.protobuf.AbstractParser() { - @Override + @java.lang.Override public SecondMsg parsePartialFrom( com.google.protobuf.CodedInputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) @@ -482,13 +442,13 @@ public static com.google.protobuf.Parser parser() { return PARSER; } - @Override + @java.lang.Override public com.google.protobuf.Parser getParserForType() { return PARSER; } - @Override - public SecondMsg getDefaultInstanceForType() { + @java.lang.Override + public org.springframework.protobuf.SecondMsg getDefaultInstanceForType() { return DEFAULT_INSTANCE; } diff --git a/spring-web/src/test/java/org/springframework/protobuf/SecondMsgOrBuilder.java b/spring-web/src/test/java/org/springframework/protobuf/SecondMsgOrBuilder.java index fdfe62258e0b..6fa02af4caac 100644 --- a/spring-web/src/test/java/org/springframework/protobuf/SecondMsgOrBuilder.java +++ b/spring-web/src/test/java/org/springframework/protobuf/SecondMsgOrBuilder.java @@ -1,5 +1,7 @@ // Generated by the protocol buffer compiler. DO NOT EDIT! +// NO CHECKED-IN PROTOBUF GENCODE // source: sample.proto +// Protobuf Java Version: 4.27.0 package org.springframework.protobuf; diff --git a/spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java b/spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java index 396ef15b3d73..9ad7ee09b98e 100644 --- a/spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java +++ b/spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java @@ -459,7 +459,7 @@ private void assertDetailMessageCode( ErrorResponse ex, @Nullable String suffix, @Nullable Object[] arguments) { assertThat(ex.getDetailMessageCode()) - .isEqualTo(ErrorResponse.getDefaultDetailMessageCode(((Exception) ex).getClass(), suffix)); + .isEqualTo(ErrorResponse.getDefaultDetailMessageCode(ex.getClass(), suffix)); if (arguments != null) { assertThat(ex.getDetailMessageArguments()).containsExactlyElementsOf(Arrays.asList(arguments)); diff --git a/spring-web/src/test/java/org/springframework/web/bind/support/WebExchangeDataBinderTests.java b/spring-web/src/test/java/org/springframework/web/bind/support/WebExchangeDataBinderTests.java index 049d4a59cb19..6bf41469e60e 100644 --- a/spring-web/src/test/java/org/springframework/web/bind/support/WebExchangeDataBinderTests.java +++ b/spring-web/src/test/java/org/springframework/web/bind/support/WebExchangeDataBinderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -330,7 +330,7 @@ public void setSomePartList(List somePartList) { } - private static class MultipartDataClass { + static class MultipartDataClass { private final FilePart part; @@ -351,4 +351,5 @@ public FilePart getNullablePart() { return nullablePart; } } + } diff --git a/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTests.java b/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTests.java index a92c05d27235..18b8815e9595 100644 --- a/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +192,7 @@ private void assertFilePart(Buffer buffer, String disposition, String boundary, } private MockResponse formRequest(RecordedRequest request) { - assertThat(request.getHeader(CONTENT_TYPE)).isEqualTo("application/x-www-form-urlencoded;charset=UTF-8"); + assertThat(request.getHeader(CONTENT_TYPE)).isEqualTo("application/x-www-form-urlencoded"); assertThat(request.getBody().readUtf8()).contains("name+1=value+1", "name+2=value+2%2B1", "name+2=value+2%2B2"); return new MockResponse().setResponseCode(200); } @@ -235,7 +235,7 @@ private MockResponse putRequest(RecordedRequest request, String expectedRequestC protected class TestDispatcher extends Dispatcher { @Override - public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + public MockResponse dispatch(RecordedRequest request) { try { byte[] helloWorldBytes = helloWorld.getBytes(StandardCharsets.UTF_8); diff --git a/spring-web/src/test/java/org/springframework/web/client/DefaultResponseErrorHandlerHttpStatusTests.java b/spring-web/src/test/java/org/springframework/web/client/DefaultResponseErrorHandlerHttpStatusTests.java index 53871f5c62a3..e7d6a94eacf0 100644 --- a/spring-web/src/test/java/org/springframework/web/client/DefaultResponseErrorHandlerHttpStatusTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/DefaultResponseErrorHandlerHttpStatusTests.java @@ -16,6 +16,7 @@ package org.springframework.web.client; +import java.net.URI; import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; @@ -24,6 +25,7 @@ import org.junit.jupiter.params.provider.MethodSource; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.client.ClientHttpResponse; @@ -62,7 +64,7 @@ class DefaultResponseErrorHandlerHttpStatusTests { private final ClientHttpResponse response = mock(); - @ParameterizedTest(name = "[{index}] error: [{0}]") + @ParameterizedTest(name = "[{index}] error: {0}") @DisplayName("hasError() returns true") @MethodSource("errorCodes") void hasErrorTrue(HttpStatus httpStatus) throws Exception { @@ -70,7 +72,7 @@ void hasErrorTrue(HttpStatus httpStatus) throws Exception { assertThat(this.handler.hasError(this.response)).isTrue(); } - @ParameterizedTest(name = "[{index}] error: {0}, exception: {1}") + @ParameterizedTest(name = "[{index}] {0} -> {1}") @DisplayName("handleError() throws an exception") @MethodSource("errorCodes") void handleErrorException(HttpStatus httpStatus, Class expectedExceptionClass) throws Exception { @@ -80,7 +82,8 @@ void handleErrorException(HttpStatus httpStatus, Class expe given(this.response.getStatusCode()).willReturn(httpStatus); given(this.response.getHeaders()).willReturn(headers); - assertThatExceptionOfType(expectedExceptionClass).isThrownBy(() -> this.handler.handleError(this.response)); + assertThatExceptionOfType(expectedExceptionClass) + .isThrownBy(() -> this.handler.handleError(URI.create("/"), HttpMethod.GET, this.response)); } static Stream errorCodes() { diff --git a/spring-web/src/test/java/org/springframework/web/client/DefaultResponseErrorHandlerTests.java b/spring-web/src/test/java/org/springframework/web/client/DefaultResponseErrorHandlerTests.java index 640abf0b877c..fbe2f9d318ca 100644 --- a/spring-web/src/test/java/org/springframework/web/client/DefaultResponseErrorHandlerTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/DefaultResponseErrorHandlerTests.java @@ -18,17 +18,20 @@ import java.io.ByteArrayInputStream; import java.io.IOException; -import java.nio.charset.StandardCharsets; +import java.net.URI; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.client.ClientHttpResponse; +import org.springframework.lang.Nullable; import org.springframework.util.StreamUtils; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.catchThrowable; @@ -69,14 +72,51 @@ void handleError() throws Exception { given(response.getStatusCode()).willReturn(HttpStatus.NOT_FOUND); given(response.getStatusText()).willReturn("Not Found"); given(response.getHeaders()).willReturn(headers); - given(response.getBody()).willReturn(new ByteArrayInputStream("Hello World".getBytes(StandardCharsets.UTF_8))); + given(response.getBody()).willReturn(new ByteArrayInputStream("Hello World".getBytes(UTF_8))); assertThatExceptionOfType(HttpClientErrorException.class) - .isThrownBy(() -> handler.handleError(response)) - .withMessage("404 Not Found: \"Hello World\"") + .isThrownBy(() -> handler.handleError(URI.create("/"), HttpMethod.GET, response)) + .withMessage("404 Not Found on GET request for \"/\": \"Hello World\"") .satisfies(ex -> assertThat(ex.getResponseHeaders()).isEqualTo(headers)); } + @Test + void handleErrorWithUrlAndMethod() throws Exception { + setupClientHttpResponse(HttpStatus.NOT_FOUND, "Hello World"); + assertThatExceptionOfType(HttpClientErrorException.class) + .isThrownBy(() -> handler.handleError(URI.create("https://example.com"), HttpMethod.GET, response)) + .withMessage("404 Not Found on GET request for \"https://example.com\": \"Hello World\""); + } + + @Test + void handleErrorWithUrlAndQueryParameters() throws Exception { + String url = "https://example.com/resource"; + setupClientHttpResponse(HttpStatus.NOT_FOUND, "Hello World"); + assertThatExceptionOfType(HttpClientErrorException.class) + .isThrownBy(() -> handler.handleError(URI.create(url + "?access_token=123"), HttpMethod.GET, response)) + .withMessage("404 Not Found on GET request for \"" + url + "\": \"Hello World\""); + } + + @Test + void handleErrorWithUrlAndNoBody() throws Exception { + String url = "https://example.com"; + setupClientHttpResponse(HttpStatus.NOT_FOUND, null); + assertThatExceptionOfType(HttpClientErrorException.class) + .isThrownBy(() -> handler.handleError(URI.create(url), HttpMethod.GET, response)) + .withMessage("404 Not Found on GET request for \"" + url + "\": [no body]"); + } + + private void setupClientHttpResponse(HttpStatus status, @Nullable String textBody) throws Exception { + HttpHeaders headers = new HttpHeaders(); + given(response.getStatusCode()).willReturn(status); + given(response.getStatusText()).willReturn(status.getReasonPhrase()); + if (textBody != null) { + headers.setContentType(MediaType.TEXT_PLAIN); + given(response.getBody()).willReturn(new ByteArrayInputStream(textBody.getBytes(UTF_8))); + } + given(response.getHeaders()).willReturn(headers); + } + @Test void handleErrorIOException() throws Exception { HttpHeaders headers = new HttpHeaders(); @@ -87,7 +127,8 @@ void handleErrorIOException() throws Exception { given(response.getHeaders()).willReturn(headers); given(response.getBody()).willThrow(new IOException()); - assertThatExceptionOfType(HttpClientErrorException.class).isThrownBy(() -> handler.handleError(response)); + assertThatExceptionOfType(HttpClientErrorException.class) + .isThrownBy(() -> handler.handleError(URI.create("/"), HttpMethod.GET, response)); } @Test @@ -100,7 +141,7 @@ void handleErrorNullResponse() throws Exception { given(response.getHeaders()).willReturn(headers); assertThatExceptionOfType(HttpClientErrorException.class).isThrownBy(() -> - handler.handleError(response)); + handler.handleError(URI.create("/"), HttpMethod.GET, response)); } @Test // SPR-16108 @@ -125,7 +166,7 @@ public void handleErrorUnknownStatusCode() throws Exception { given(response.getHeaders()).willReturn(headers); assertThatExceptionOfType(UnknownHttpStatusCodeException.class).isThrownBy(() -> - handler.handleError(response)); + handler.handleError(URI.create("/"), HttpMethod.GET, response)); } @Test // SPR-17461 @@ -149,14 +190,14 @@ void handleErrorForCustomClientError() throws Exception { headers.setContentType(MediaType.TEXT_PLAIN); String responseBody = "Hello World"; - TestByteArrayInputStream body = new TestByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8)); + TestByteArrayInputStream body = new TestByteArrayInputStream(responseBody.getBytes(UTF_8)); given(response.getStatusCode()).willReturn(statusCode); given(response.getStatusText()).willReturn(statusText); given(response.getHeaders()).willReturn(headers); given(response.getBody()).willReturn(body); - Throwable throwable = catchThrowable(() -> handler.handleError(response)); + Throwable throwable = catchThrowable(() -> handler.handleError(URI.create("/"), HttpMethod.GET, response)); // validate exception assertThat(throwable).isInstanceOf(HttpClientErrorException.class); @@ -189,14 +230,14 @@ void handleErrorForCustomServerError() throws Exception { headers.setContentType(MediaType.TEXT_PLAIN); String responseBody = "Hello World"; - TestByteArrayInputStream body = new TestByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8)); + TestByteArrayInputStream body = new TestByteArrayInputStream(responseBody.getBytes(UTF_8)); given(response.getStatusCode()).willReturn(statusCode); given(response.getStatusText()).willReturn(statusText); given(response.getHeaders()).willReturn(headers); given(response.getBody()).willReturn(body); - Throwable throwable = catchThrowable(() -> handler.handleError(response)); + Throwable throwable = catchThrowable(() -> handler.handleError(URI.create("/"), HttpMethod.GET, response)); // validate exception assertThat(throwable).isInstanceOf(HttpServerErrorException.class); @@ -212,7 +253,7 @@ void handleErrorForCustomServerError() throws Exception { public void bodyAvailableAfterHasErrorForUnknownStatusCode() throws Exception { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.TEXT_PLAIN); - TestByteArrayInputStream body = new TestByteArrayInputStream("Hello World".getBytes(StandardCharsets.UTF_8)); + TestByteArrayInputStream body = new TestByteArrayInputStream("Hello World".getBytes(UTF_8)); given(response.getStatusCode()).willReturn(HttpStatusCode.valueOf(999)); given(response.getStatusText()).willReturn("Custom status code"); @@ -221,7 +262,7 @@ public void bodyAvailableAfterHasErrorForUnknownStatusCode() throws Exception { assertThat(handler.hasError(response)).isFalse(); assertThat(body.isClosed()).isFalse(); - assertThat(StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8)).isEqualTo("Hello World"); + assertThat(StreamUtils.copyToString(response.getBody(), UTF_8)).isEqualTo("Hello World"); } diff --git a/spring-web/src/test/java/org/springframework/web/client/ExtractingResponseErrorHandlerTests.java b/spring-web/src/test/java/org/springframework/web/client/ExtractingResponseErrorHandlerTests.java index e28b623cf5e4..1af510711285 100644 --- a/spring-web/src/test/java/org/springframework/web/client/ExtractingResponseErrorHandlerTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/ExtractingResponseErrorHandlerTests.java @@ -17,13 +17,17 @@ package org.springframework.web.client; import java.io.ByteArrayInputStream; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.Collections; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.client.ClientHttpResponse; @@ -36,8 +40,11 @@ import static org.mockito.Mockito.mock; /** + * Unit tests for {@link ExtractingResponseErrorHandler}. + * * @author Arjen Poutsma */ +@SuppressWarnings("ALL") class ExtractingResponseErrorHandlerTests { private ExtractingResponseErrorHandler errorHandler; @@ -48,13 +55,10 @@ class ExtractingResponseErrorHandlerTests { @BeforeEach void setup() { HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); - this.errorHandler = new ExtractingResponseErrorHandler( - Collections.singletonList(converter)); + this.errorHandler = new ExtractingResponseErrorHandler(List.of(converter)); - this.errorHandler.setStatusMapping( - Collections.singletonMap(HttpStatus.I_AM_A_TEAPOT, MyRestClientException.class)); - this.errorHandler.setSeriesMapping(Collections - .singletonMap(HttpStatus.Series.SERVER_ERROR, MyRestClientException.class)); + this.errorHandler.setStatusMapping(Map.of(HttpStatus.I_AM_A_TEAPOT, MyRestClientException.class)); + this.errorHandler.setSeriesMapping(Map.of(HttpStatus.Series.SERVER_ERROR, MyRestClientException.class)); } @@ -72,8 +76,7 @@ void hasError() throws Exception { @Test void hasErrorOverride() throws Exception { - this.errorHandler.setSeriesMapping(Collections - .singletonMap(HttpStatus.Series.CLIENT_ERROR, null)); + this.errorHandler.setSeriesMapping(Collections.singletonMap(HttpStatus.Series.CLIENT_ERROR, null)); given(this.response.getStatusCode()).willReturn(HttpStatus.I_AM_A_TEAPOT); assertThat(this.errorHandler.hasError(this.response)).isTrue(); @@ -96,9 +99,9 @@ void handleErrorStatusMatch() throws Exception { responseHeaders.setContentLength(body.length); given(this.response.getBody()).willReturn(new ByteArrayInputStream(body)); - assertThatExceptionOfType(MyRestClientException.class).isThrownBy(() -> - this.errorHandler.handleError(this.response)) - .satisfies(ex -> assertThat(ex.getFoo()).isEqualTo("bar")); + assertThatExceptionOfType(MyRestClientException.class) + .isThrownBy(() -> this.errorHandler.handleError(URI.create("/"), HttpMethod.GET, this.response)) + .satisfies(ex -> assertThat(ex.getFoo()).isEqualTo("bar")); } @Test @@ -112,9 +115,9 @@ void handleErrorSeriesMatch() throws Exception { responseHeaders.setContentLength(body.length); given(this.response.getBody()).willReturn(new ByteArrayInputStream(body)); - assertThatExceptionOfType(MyRestClientException.class).isThrownBy(() -> - this.errorHandler.handleError(this.response)) - .satisfies(ex -> assertThat(ex.getFoo()).isEqualTo("bar")); + assertThatExceptionOfType(MyRestClientException.class) + .isThrownBy(() -> this.errorHandler.handleError(URI.create("/"), HttpMethod.GET, this.response)) + .satisfies(ex -> assertThat(ex.getFoo()).isEqualTo("bar")); } @Test @@ -128,18 +131,17 @@ void handleNoMatch() throws Exception { responseHeaders.setContentLength(body.length); given(this.response.getBody()).willReturn(new ByteArrayInputStream(body)); - assertThatExceptionOfType(HttpClientErrorException.class).isThrownBy(() -> - this.errorHandler.handleError(this.response)) - .satisfies(ex -> { - assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); - assertThat(ex.getResponseBodyAsByteArray()).isEqualTo(body); - }); + assertThatExceptionOfType(HttpClientErrorException.class) + .isThrownBy(() -> this.errorHandler.handleError(URI.create("/"), HttpMethod.GET, this.response)) + .satisfies(ex -> { + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(ex.getResponseBodyAsByteArray()).isEqualTo(body); + }); } @Test void handleNoMatchOverride() throws Exception { - this.errorHandler.setSeriesMapping(Collections - .singletonMap(HttpStatus.Series.CLIENT_ERROR, null)); + this.errorHandler.setSeriesMapping(Collections.singletonMap(HttpStatus.Series.CLIENT_ERROR, null)); given(this.response.getStatusCode()).willReturn(HttpStatus.NOT_FOUND); HttpHeaders responseHeaders = new HttpHeaders(); @@ -150,7 +152,7 @@ void handleNoMatchOverride() throws Exception { responseHeaders.setContentLength(body.length); given(this.response.getBody()).willReturn(new ByteArrayInputStream(body)); - this.errorHandler.handleError(this.response); + this.errorHandler.handleError(URI.create("/"), HttpMethod.GET, this.response); } diff --git a/spring-web/src/test/java/org/springframework/web/client/HttpMessageConverterExtractorTests.java b/spring-web/src/test/java/org/springframework/web/client/HttpMessageConverterExtractorTests.java index d5de3c8b0c8c..ba26b2109f98 100644 --- a/spring-web/src/test/java/org/springframework/web/client/HttpMessageConverterExtractorTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/HttpMessageConverterExtractorTests.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpStatus; @@ -33,12 +34,14 @@ import org.springframework.http.converter.GenericHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.SmartHttpMessageConverter; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; @@ -166,6 +169,26 @@ void generics() throws IOException { assertThat(result).isEqualTo(expected); } + @Test + void smartConverter() throws IOException { + responseHeaders.setContentType(contentType); + String expected = "Foo"; + ParameterizedTypeReference> reference = new ParameterizedTypeReference<>() {}; + ResolvableType resolvableType = ResolvableType.forType(reference.getType()); + + SmartHttpMessageConverter converter = mock(); + HttpMessageConverterExtractor extractor = new HttpMessageConverterExtractor>(resolvableType.getType(), List.of(converter)); + + given(response.getStatusCode()).willReturn(HttpStatus.OK); + given(response.getHeaders()).willReturn(responseHeaders); + given(response.getBody()).willReturn(new ByteArrayInputStream(expected.getBytes())); + given(converter.canRead(resolvableType, contentType)).willReturn(true); + given(converter.read(eq(resolvableType), any(HttpInputMessage.class), isNull())).willReturn(expected); + + Object result = extractor.extractData(response); + assertThat(result).isEqualTo(expected); + } + @Test // SPR-13592 void converterThrowsIOException() throws IOException { responseHeaders.setContentType(contentType); diff --git a/spring-web/src/test/java/org/springframework/web/client/RestClientBuilderTests.java b/spring-web/src/test/java/org/springframework/web/client/RestClientBuilderTests.java index 3a4f43ff84c2..e328bcfb8499 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestClientBuilderTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestClientBuilderTests.java @@ -17,8 +17,13 @@ package org.springframework.web.client; import java.lang.reflect.Field; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Map; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.springframework.http.client.ClientHttpRequestInitializer; @@ -31,11 +36,14 @@ import org.springframework.web.util.DefaultUriBuilderFactory; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.fail; /** * @author Arjen Poutsma + * @author Sebastien Deleuze + * @author Nicklas Wiegandt */ public class RestClientBuilderTests { @@ -90,6 +98,164 @@ void defaultUriBuilderFactory() { assertThat(fieldValue("uriBuilderFactory", defaultBuilder)).isNull(); } + @Test + void defaultUri() { + URI baseUrl = URI.create("https://example.org"); + RestClient.Builder builder = RestClient.builder(); + builder.baseUrl(baseUrl); + + assertThat(builder).isInstanceOf(DefaultRestClientBuilder.class); + DefaultRestClientBuilder defaultBuilder = (DefaultRestClientBuilder) builder; + + assertThat(fieldValue("baseUrl", defaultBuilder)).isEqualTo(baseUrl.toString()); + } + + @Test + void messageConvertersList() { + StringHttpMessageConverter stringConverter = new StringHttpMessageConverter(); + RestClient.Builder builder = RestClient.builder(); + builder.messageConverters(List.of(stringConverter)); + + assertThat(builder).isInstanceOf(DefaultRestClientBuilder.class); + DefaultRestClientBuilder defaultBuilder = (DefaultRestClientBuilder) builder; + + assertThat(fieldValue("messageConverters", defaultBuilder)) + .asInstanceOf(InstanceOfAssertFactories.LIST) + .containsExactly(stringConverter); + } + + @Test + void messageConvertersListEmpty() { + RestClient.Builder builder = RestClient.builder(); + List> converters = Collections.emptyList(); + assertThatIllegalArgumentException().isThrownBy(() -> builder.messageConverters(converters)); + } + + @Test + void messageConvertersListWithNullElement() { + RestClient.Builder builder = RestClient.builder(); + List> converters = new ArrayList<>(); + converters.add(null); + assertThatIllegalArgumentException().isThrownBy(() -> builder.messageConverters(converters)); + } + + @Test + void defaultCookieAddsCookieToDefaultCookiesMap() { + RestClient.Builder builder = RestClient.builder(); + + builder.defaultCookie("myCookie", "testValue"); + + assertThat(fieldValue("defaultCookies", (DefaultRestClientBuilder) builder)) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsExactly(Map.entry("myCookie", List.of("testValue"))); + } + + @Test + void defaultCookieWithMultipleValuesAddsCookieToDefaultCookiesMap() { + RestClient.Builder builder = RestClient.builder(); + + builder.defaultCookie("myCookie", "testValue1", "testValue2"); + + assertThat(fieldValue("defaultCookies", (DefaultRestClientBuilder) builder)) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsExactly(Map.entry("myCookie", List.of("testValue1", "testValue2"))); + } + + @Test + void defaultCookiesAllowsToAddCookie() { + RestClient.Builder builder = RestClient.builder(); + builder.defaultCookie("firstCookie", "firstValue"); + + builder.defaultCookies(cookies -> cookies.add("secondCookie", "secondValue")); + + assertThat(fieldValue("defaultCookies", (DefaultRestClientBuilder) builder)) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsExactly( + Map.entry("firstCookie", List.of("firstValue")), + Map.entry("secondCookie", List.of("secondValue")) + ); + } + + @Test + void defaultCookiesAllowsToRemoveCookie() { + RestClient.Builder builder = RestClient.builder(); + builder.defaultCookie("firstCookie", "firstValue"); + builder.defaultCookie("secondCookie", "secondValue"); + + builder.defaultCookies(cookies -> cookies.remove("firstCookie")); + + assertThat(fieldValue("defaultCookies", (DefaultRestClientBuilder) builder)) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsExactly(Map.entry("secondCookie", List.of("secondValue"))); + } + + @Test + void copyConstructorCopiesDefaultCookies() { + DefaultRestClientBuilder sourceBuilder = new DefaultRestClientBuilder(); + sourceBuilder.defaultCookie("firstCookie", "firstValue"); + sourceBuilder.defaultCookie("secondCookie", "secondValue"); + + DefaultRestClientBuilder copiedBuilder = new DefaultRestClientBuilder(sourceBuilder); + + assertThat(fieldValue("defaultCookies", copiedBuilder)) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsExactly( + Map.entry("firstCookie", List.of("firstValue")), + Map.entry("secondCookie", List.of("secondValue")) + ); + } + + @Test + void copyConstructorCopiesDefaultCookiesImmutable() { + DefaultRestClientBuilder sourceBuilder = new DefaultRestClientBuilder(); + sourceBuilder.defaultCookie("firstCookie", "firstValue"); + sourceBuilder.defaultCookie("secondCookie", "secondValue"); + DefaultRestClientBuilder copiedBuilder = new DefaultRestClientBuilder(sourceBuilder); + + sourceBuilder.defaultCookie("thirdCookie", "thirdValue"); + + assertThat(fieldValue("defaultCookies", copiedBuilder)) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsExactly( + Map.entry("firstCookie", List.of("firstValue")), + Map.entry("secondCookie", List.of("secondValue")) + ); + } + + @Test + void buildCopiesDefaultCookies() { + RestClient.Builder builder = RestClient.builder(); + builder.defaultCookie("firstCookie", "firstValue"); + builder.defaultCookie("secondCookie", "secondValue"); + + RestClient restClient = builder.build(); + + assertThat(fieldValue("defaultCookies", restClient)) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsExactly( + Map.entry("firstCookie", List.of("firstValue")), + Map.entry("secondCookie", List.of("secondValue")) + ); + } + + @Test + void buildCopiesDefaultCookiesImmutable() { + RestClient.Builder builder = RestClient.builder(); + builder.defaultCookie("firstCookie", "firstValue"); + builder.defaultCookie("secondCookie", "secondValue"); + RestClient restClient = builder.build(); + + builder.defaultCookie("thirdCookie", "thirdValue"); + builder.defaultCookie("firstCookie", "fourthValue"); + + assertThat(fieldValue("defaultCookies", restClient)) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsExactly( + Map.entry("firstCookie", List.of("firstValue")), + Map.entry("secondCookie", List.of("secondValue")) + ); + } + @Nullable private static Object fieldValue(String name, DefaultRestClientBuilder instance) { try { @@ -103,4 +269,18 @@ private static Object fieldValue(String name, DefaultRestClientBuilder instance) return null; } } + + @Nullable + private static Object fieldValue(String name, RestClient instance) { + try { + Field field = DefaultRestClient.class.getDeclaredField(name); + field.setAccessible(true); + + return field.get(instance); + } + catch (NoSuchFieldException | IllegalAccessException ex) { + fail(ex.getMessage(), ex); + return null; + } + } } diff --git a/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java b/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java index 673ae08cc799..916116fa45d1 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java @@ -21,7 +21,8 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import java.nio.charset.StandardCharsets; +import java.net.URI; +import java.net.URISyntaxException; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -31,8 +32,8 @@ import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Named; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.core.ParameterizedTypeReference; @@ -47,17 +48,18 @@ import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.http.client.JettyClientHttpRequestFactory; -import org.springframework.http.client.ReactorNettyClientRequestFactory; +import org.springframework.http.client.ReactorClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.testfixture.xml.Pojo; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.api.Assumptions.assumeFalse; -import static org.junit.jupiter.api.Named.named; +import static org.junit.jupiter.params.provider.Arguments.argumentSet; /** * Integration tests for {@link RestClient}. @@ -69,20 +71,20 @@ class RestClientIntegrationTests { @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) - @ParameterizedTest(name = "[{index}] {0}") + @ParameterizedTest @MethodSource("clientHttpRequestFactories") @interface ParameterizedRestClientTest { } @SuppressWarnings("removal") - static Stream> clientHttpRequestFactories() { + static Stream clientHttpRequestFactories() { return Stream.of( - named("JDK HttpURLConnection", new SimpleClientHttpRequestFactory()), - named("HttpComponents", new HttpComponentsClientHttpRequestFactory()), - named("OkHttp", new org.springframework.http.client.OkHttp3ClientHttpRequestFactory()), - named("Jetty", new JettyClientHttpRequestFactory()), - named("JDK HttpClient", new JdkClientHttpRequestFactory()), - named("Reactor Netty", new ReactorNettyClientRequestFactory()) + argumentSet("JDK HttpURLConnection", new SimpleClientHttpRequestFactory()), + argumentSet("HttpComponents", new HttpComponentsClientHttpRequestFactory()), + argumentSet("OkHttp", new org.springframework.http.client.OkHttp3ClientHttpRequestFactory()), + argumentSet("Jetty", new JettyClientHttpRequestFactory()), + argumentSet("JDK HttpClient", new JdkClientHttpRequestFactory()), + argumentSet("Reactor Netty", new ReactorClientHttpRequestFactory()) ); } @@ -658,8 +660,8 @@ void statusHandlerSuppressedErrorSignalWithEntity(ClientHttpRequestFactory reque startServer(requestFactory); String content = "Internal Server error"; - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody(content)); + prepareResponse(response -> + response.setResponseCode(500).setHeader("Content-Type", "text/plain").setBody(content)); ResponseEntity result = this.restClient.get() .uri("/").accept(MediaType.APPLICATION_JSON) @@ -687,7 +689,7 @@ void exchangeForPlainText(ClientHttpRequestFactory requestFactory) { String result = this.restClient.get() .uri("/greeting") .header("X-Test-Header", "testvalue") - .exchange((request, response) -> new String(RestClientUtils.getBody(response), StandardCharsets.UTF_8)); + .exchange((request, response) -> new String(RestClientUtils.getBody(response), UTF_8)); assertThat(result).isEqualTo("Hello Spring!"); @@ -751,12 +753,12 @@ void exchangeForJsonArray(ClientHttpRequestFactory requestFactory) { void exchangeFor404(ClientHttpRequestFactory requestFactory) { startServer(requestFactory); - prepareResponse(response -> response.setResponseCode(404) - .setHeader("Content-Type", "text/plain").setBody("Not Found")); + prepareResponse(response -> + response.setResponseCode(404).setHeader("Content-Type", "text/plain").setBody("Not Found")); String result = this.restClient.get() .uri("/greeting") - .exchange((request, response) -> new String(RestClientUtils.getBody(response), StandardCharsets.UTF_8)); + .exchange((request, response) -> new String(RestClientUtils.getBody(response), UTF_8)); assertThat(result).isEqualTo("Not Found"); @@ -768,8 +770,8 @@ void exchangeFor404(ClientHttpRequestFactory requestFactory) { void requestInitializer(ClientHttpRequestFactory requestFactory) { startServer(requestFactory); - prepareResponse(response -> response.setHeader("Content-Type", "text/plain") - .setBody("Hello Spring!")); + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); RestClient initializedClient = this.restClient.mutate() .requestInitializer(request -> request.getHeaders().add("foo", "bar")) @@ -790,9 +792,8 @@ void requestInitializer(ClientHttpRequestFactory requestFactory) { void requestInterceptor(ClientHttpRequestFactory requestFactory) { startServer(requestFactory); - prepareResponse(response -> response.setHeader("Content-Type", "text/plain") - .setBody("Hello Spring!")); - + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); RestClient interceptedClient = this.restClient.mutate() .requestInterceptor((request, body, execution) -> { @@ -812,6 +813,27 @@ void requestInterceptor(ClientHttpRequestFactory requestFactory) { expectRequest(request -> assertThat(request.getHeader("foo")).isEqualTo("bar")); } + @ParameterizedRestClientTest + void retrieveDefaultCookiesAsCookieHeader(ClientHttpRequestFactory requestFactory) { + startServer(requestFactory); + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + + RestClient restClientWithCookies = this.restClient.mutate() + .defaultCookie("testCookie", "firstValue", "secondValue") + .build(); + + restClientWithCookies.get() + .uri("/greeting") + .header("X-Test-Header", "testvalue") + .retrieve() + .body(String.class); + + expectRequest(request -> + assertThat(request.getHeader(HttpHeaders.COOKIE)) + .isEqualTo("testCookie=firstValue; testCookie=secondValue") + ); + } @ParameterizedRestClientTest void filterForErrorHandling(ClientHttpRequestFactory requestFactory) { @@ -831,8 +853,8 @@ void filterForErrorHandling(ClientHttpRequestFactory requestFactory) { RestClient interceptedClient = this.restClient.mutate().requestInterceptor(interceptor).build(); // header not present - prepareResponse(response -> response - .setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); assertThatExceptionOfType(MyException.class).isThrownBy(() -> interceptedClient.get() @@ -860,8 +882,8 @@ void filterForErrorHandling(ClientHttpRequestFactory requestFactory) { void defaultHeaders(ClientHttpRequestFactory requestFactory) { startServer(requestFactory); - prepareResponse(response -> response.setHeader("Content-Type", "text/plain") - .setBody("Hello Spring!")); + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); RestClient headersClient = this.restClient.mutate() .defaultHeaders(headers -> headers.add("foo", "bar")) @@ -882,8 +904,8 @@ void defaultHeaders(ClientHttpRequestFactory requestFactory) { void defaultRequest(ClientHttpRequestFactory requestFactory) { startServer(requestFactory); - prepareResponse(response -> response.setHeader("Content-Type", "text/plain") - .setBody("Hello Spring!")); + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); RestClient headersClient = this.restClient.mutate() .defaultRequest(request -> request.header("foo", "bar")) @@ -900,6 +922,106 @@ void defaultRequest(ClientHttpRequestFactory requestFactory) { expectRequest(request -> assertThat(request.getHeader("foo")).isEqualTo("bar")); } + @ParameterizedRestClientTest + void defaultRequestOverride(ClientHttpRequestFactory requestFactory) { + startServer(requestFactory); + + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + + RestClient headersClient = this.restClient.mutate() + .defaultRequest(request -> request.accept(MediaType.APPLICATION_JSON)) + .build(); + + String result = headersClient.get() + .uri("/greeting") + .accept(MediaType.TEXT_PLAIN) + .retrieve() + .body(String.class); + + assertThat(result).isEqualTo("Hello Spring!"); + + expectRequestCount(1); + expectRequest(request -> assertThat(request.getHeader("Accept")).isEqualTo(MediaType.TEXT_PLAIN_VALUE)); + } + + @ParameterizedRestClientTest + void relativeUri(ClientHttpRequestFactory requestFactory) throws URISyntaxException { + startServer(requestFactory); + + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + + URI uri = new URI(null, null, "/foo bar", null); + + String result = this.restClient + .get() + .uri(uri) + .accept(MediaType.TEXT_PLAIN) + .retrieve() + .body(String.class); + + assertThat(result).isEqualTo("Hello Spring!"); + + expectRequestCount(1); + expectRequest(request -> assertThat(request.getPath()).isEqualTo("/foo%20bar")); + } + + @ParameterizedRestClientTest + void cookieAddsCookie(ClientHttpRequestFactory requestFactory) { + startServer(requestFactory); + + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + + this.restClient.get() + .uri("/greeting") + .cookie("c1", "v1a") + .cookie("c1", "v1b") + .cookie("c2", "v2a") + .retrieve() + .body(String.class); + + expectRequest(request -> assertThat(request.getHeader("Cookie")).isEqualTo("c1=v1a; c1=v1b; c2=v2a")); + } + + @ParameterizedRestClientTest + void cookieOverridesDefaultCookie(ClientHttpRequestFactory requestFactory) { + startServer(requestFactory); + + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + + RestClient restClientWithCookies = this.restClient.mutate() + .defaultCookie("testCookie", "firstValue", "secondValue") + .build(); + + restClientWithCookies.get() + .uri("/greeting") + .cookie("testCookie", "test") + .retrieve() + .body(String.class); + + expectRequest(request -> assertThat(request.getHeader("Cookie")).isEqualTo("testCookie=test")); + } + + @ParameterizedRestClientTest + void cookiesCanRemoveCookie(ClientHttpRequestFactory requestFactory) { + startServer(requestFactory); + + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + + this.restClient.get() + .uri("/greeting") + .cookie("foo", "bar") + .cookie("test", "Hello") + .cookies(cookies -> cookies.remove("foo")) + .retrieve() + .body(String.class); + + expectRequest(request -> assertThat(request.getHeader("Cookie")).isEqualTo("test=Hello")); + } private void prepareResponse(Consumer consumer) { MockResponse response = new MockResponse(); diff --git a/spring-web/src/test/java/org/springframework/web/client/RestClientObservationTests.java b/spring-web/src/test/java/org/springframework/web/client/RestClientObservationTests.java index 8181151af9fa..1624f25719eb 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestClientObservationTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestClientObservationTests.java @@ -83,6 +83,7 @@ void setupEach() { RestClient.Builder createBuilder() { return RestClient.builder() + .baseUrl("https://example.com/base") .messageConverters(converters -> converters.add(0, this.converter)) .requestFactory(this.requestFactory) .observationRegistry(this.observationRegistry); @@ -90,26 +91,37 @@ RestClient.Builder createBuilder() { @Test void shouldContributeTemplateWhenUriVariables() throws Exception { - mockSentRequest(GET, "https://example.com/hotels/42/bookings/21"); + mockSentRequest(GET, "https://example.com/base/hotels/42/bookings/21"); mockResponseStatus(HttpStatus.OK); - client.get().uri("https://example.com/hotels/{hotel}/bookings/{booking}", "42", "21") + client.get().uri("/hotels/{hotel}/bookings/{booking}", "42", "21") .retrieve().toBodilessEntity(); - assertThatHttpObservation().hasLowCardinalityKeyValue("uri", "/hotels/{hotel}/bookings/{booking}"); + assertThatHttpObservation().hasLowCardinalityKeyValue("uri", "/base/hotels/{hotel}/bookings/{booking}"); } @Test void shouldContributeTemplateWhenMap() throws Exception { - mockSentRequest(GET, "https://example.com/hotels/42/bookings/21"); + mockSentRequest(GET, "https://example.com/base/hotels/42/bookings/21"); mockResponseStatus(HttpStatus.OK); Map vars = Map.of("hotel", "42", "booking", "21"); - client.get().uri("https://example.com/hotels/{hotel}/bookings/{booking}", vars) + client.get().uri("/hotels/{hotel}/bookings/{booking}", vars) .retrieve().toBodilessEntity(); - assertThatHttpObservation().hasLowCardinalityKeyValue("uri", "/hotels/{hotel}/bookings/{booking}"); + assertThatHttpObservation().hasLowCardinalityKeyValue("uri", "/base/hotels/{hotel}/bookings/{booking}"); + } + + @Test + void shouldContributeTemplateWhenFunction() throws Exception { + mockSentRequest(GET, "https://example.com/base/hotels/42/bookings/21"); + mockResponseStatus(HttpStatus.OK); + + client.get().uri("/hotels/{hotel}/bookings/{booking}", builder -> builder.build("42", "21")) + .retrieve().toBodilessEntity(); + + assertThatHttpObservation().hasLowCardinalityKeyValue("uri", "/base/hotels/{hotel}/bookings/{booking}"); } @Test @@ -178,8 +190,7 @@ void shouldUseCustomConvention() throws Exception { restClient.get().uri("https://example.org").retrieve().toBodilessEntity(); - TestObservationRegistryAssert.assertThat(this.observationRegistry) - .hasObservationWithNameEqualTo("custom.requests"); + assertThat(this.observationRegistry).hasObservationWithNameEqualTo("custom.requests"); } @Test @@ -289,9 +300,8 @@ private void mockResponseBody(String expectedBody, MediaType mediaType) throws E private TestObservationRegistryAssert.TestObservationRegistryAssertReturningObservationContextAssert assertThatHttpObservation() { - TestObservationRegistryAssert.assertThat(this.observationRegistry).hasNumberOfObservationsWithNameEqualTo("http.client.requests",1); - return TestObservationRegistryAssert.assertThat(this.observationRegistry) - .hasObservationWithNameEqualTo("http.client.requests").that(); + assertThat(this.observationRegistry).hasNumberOfObservationsWithNameEqualTo("http.client.requests",1); + return assertThat(this.observationRegistry).hasObservationWithNameEqualTo("http.client.requests").that(); } static class ContextAssertionObservationHandler implements ObservationHandler { @@ -335,12 +345,12 @@ static class ObservationErrorHandler implements ResponseErrorHandler { } @Override - public boolean hasError(ClientHttpResponse response) throws IOException { + public boolean hasError(ClientHttpResponse response) { return true; } @Override - public void handleError(ClientHttpResponse response) throws IOException { + public void handleError(URI uri, HttpMethod httpMethod, ClientHttpResponse response) { assertThat(this.observationRegistry.getCurrentObservationScope()).isNotNull(); } } diff --git a/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java b/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java index 6f20c7ff1101..a189a518d14b 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java @@ -30,10 +30,10 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeName; import com.fasterxml.jackson.annotation.JsonView; -import org.junit.jupiter.api.Named; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.extension.TestExecutionExceptionHandler; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.core.ParameterizedTypeReference; @@ -50,7 +50,7 @@ import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.http.client.JettyClientHttpRequestFactory; -import org.springframework.http.client.ReactorNettyClientRequestFactory; +import org.springframework.http.client.ReactorClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.http.converter.json.MappingJacksonValue; @@ -60,7 +60,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.api.Assumptions.assumeFalse; -import static org.junit.jupiter.api.Named.named; +import static org.junit.jupiter.params.provider.Arguments.argumentSet; import static org.springframework.http.HttpMethod.POST; import static org.springframework.http.MediaType.MULTIPART_MIXED; @@ -85,20 +85,20 @@ class RestTemplateIntegrationTests extends AbstractMockWebServerTests { @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) - @ParameterizedTest(name = "[{index}] {0}") + @ParameterizedTest @MethodSource("clientHttpRequestFactories") @interface ParameterizedRestTemplateTest { } @SuppressWarnings("removal") - static Stream> clientHttpRequestFactories() { + static Stream clientHttpRequestFactories() { return Stream.of( - named("JDK HttpURLConnection", new SimpleClientHttpRequestFactory()), - named("HttpComponents", new HttpComponentsClientHttpRequestFactory()), - named("OkHttp", new org.springframework.http.client.OkHttp3ClientHttpRequestFactory()), - named("Jetty", new JettyClientHttpRequestFactory()), - named("JDK HttpClient", new JdkClientHttpRequestFactory()), - named("Reactor Netty", new ReactorNettyClientRequestFactory()) + argumentSet("JDK HttpURLConnection", new SimpleClientHttpRequestFactory()), + argumentSet("HttpComponents", new HttpComponentsClientHttpRequestFactory()), + argumentSet("OkHttp", new org.springframework.http.client.OkHttp3ClientHttpRequestFactory()), + argumentSet("Jetty", new JettyClientHttpRequestFactory()), + argumentSet("JDK HttpClient", new JdkClientHttpRequestFactory()), + argumentSet("Reactor Netty", new ReactorClientHttpRequestFactory()) ); } @@ -241,12 +241,16 @@ void patchForObject(ClientHttpRequestFactory clientHttpRequestFactory) { void notFound(ClientHttpRequestFactory clientHttpRequestFactory) { setUpClient(clientHttpRequestFactory); + String url = baseUrl + "/status/notfound"; assertThatExceptionOfType(HttpClientErrorException.class).isThrownBy(() -> - template.execute(baseUrl + "/status/notfound", HttpMethod.GET, null, null)) + template.execute(url, HttpMethod.GET, null, null)) .satisfies(ex -> { assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); assertThat(ex.getStatusText()).isNotNull(); assertThat(ex.getResponseBodyAsString()).isNotNull(); + assertThat(ex.getMessage()).containsSubsequence("404", "on GET request for \"" + url + "\": [no body]"); + assumeFalse(clientHttpRequestFactory instanceof JdkClientHttpRequestFactory, "JDK HttpClient does not expose status text"); + assertThat(ex.getMessage()).isEqualTo("404 Client Error on GET request for \"" + url + "\": [no body]"); }); } @@ -254,12 +258,14 @@ void notFound(ClientHttpRequestFactory clientHttpRequestFactory) { void badRequest(ClientHttpRequestFactory clientHttpRequestFactory) { setUpClient(clientHttpRequestFactory); + String url = baseUrl + "/status/badrequest"; assertThatExceptionOfType(HttpClientErrorException.class).isThrownBy(() -> - template.execute(baseUrl + "/status/badrequest", HttpMethod.GET, null, null)) + template.execute(url, HttpMethod.GET, null, null)) .satisfies(ex -> { assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(ex.getMessage()).containsSubsequence("400", "on GET request for \""+url+ "\": [no body]"); assumeFalse(clientHttpRequestFactory instanceof JdkClientHttpRequestFactory, "JDK HttpClient does not expose status text"); - assertThat(ex.getMessage()).isEqualTo("400 Client Error: [no body]"); + assertThat(ex.getMessage()).isEqualTo("400 Client Error on GET request for \""+url+ "\": [no body]"); }); } @@ -267,12 +273,16 @@ void badRequest(ClientHttpRequestFactory clientHttpRequestFactory) { void serverError(ClientHttpRequestFactory clientHttpRequestFactory) { setUpClient(clientHttpRequestFactory); + String url = baseUrl + "/status/server"; assertThatExceptionOfType(HttpServerErrorException.class).isThrownBy(() -> - template.execute(baseUrl + "/status/server", HttpMethod.GET, null, null)) + template.execute(url, HttpMethod.GET, null, null)) .satisfies(ex -> { assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); assertThat(ex.getStatusText()).isNotNull(); assertThat(ex.getResponseBodyAsString()).isNotNull(); + assertThat(ex.getMessage()).containsSubsequence("500", "on GET request for \"" + url + "\": [no body]"); + assumeFalse(clientHttpRequestFactory instanceof JdkClientHttpRequestFactory, "JDK HttpClient does not expose status text"); + assertThat(ex.getMessage()).isEqualTo("500 Server Error on GET request for \"" + url + "\": [no body]"); }); } diff --git a/spring-web/src/test/java/org/springframework/web/client/RestTemplateObservationTests.java b/spring-web/src/test/java/org/springframework/web/client/RestTemplateObservationTests.java index 042477227f4a..b8a2ad99d313 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestTemplateObservationTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestTemplateObservationTests.java @@ -214,8 +214,7 @@ private void mockResponseBody(String expectedBody, MediaType mediaType) throws E private TestObservationRegistryAssert.TestObservationRegistryAssertReturningObservationContextAssert assertThatHttpObservation() { - return TestObservationRegistryAssert.assertThat(this.observationRegistry) - .hasObservationWithNameEqualTo("http.client.requests").that(); + return assertThat(this.observationRegistry).hasObservationWithNameEqualTo("http.client.requests").that(); } static class ContextAssertionObservationHandler implements ObservationHandler { @@ -243,12 +242,12 @@ static class ObservationErrorHandler implements ResponseErrorHandler { } @Override - public boolean hasError(ClientHttpResponse response) throws IOException { + public boolean hasError(ClientHttpResponse response) { return true; } @Override - public void handleError(ClientHttpResponse response) throws IOException { + public void handleError(URI uri, HttpMethod httpMethod, ClientHttpResponse response) { currentObservation = this.observationRegistry.getCurrentObservation(); } } diff --git a/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java b/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java index 6cb5e2b52e9c..cf7f6fadd44e 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java @@ -35,6 +35,7 @@ import org.junit.jupiter.api.Test; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpInputMessage; @@ -50,6 +51,7 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.http.converter.GenericHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.SmartHttpMessageConverter; import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.web.util.DefaultUriBuilderFactory; @@ -683,6 +685,41 @@ void exchangeParameterizedType() throws Exception { verify(response).close(); } + @Test + @SuppressWarnings("rawtypes") + void exchangeParameterizedTypeWithSmartConverter() throws Exception { + SmartHttpMessageConverter converter = mock(); + template.setMessageConverters(Collections.singletonList(converter)); + ParameterizedTypeReference> intList = new ParameterizedTypeReference<>() {}; + given(converter.canRead(ResolvableType.forType(intList.getType()), null)).willReturn(true); + given(converter.getSupportedMediaTypes(any())).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); + given(converter.canWrite(ResolvableType.forClass(String.class), String.class, null)).willReturn(true); + + HttpHeaders requestHeaders = new HttpHeaders(); + mockSentRequest(POST, "https://example.com", requestHeaders); + List expected = Collections.singletonList(42); + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setContentType(MediaType.TEXT_PLAIN); + responseHeaders.setContentLength(10); + mockResponseStatus(HttpStatus.OK); + given(response.getHeaders()).willReturn(responseHeaders); + given(response.getBody()).willReturn(new ByteArrayInputStream(Integer.toString(42).getBytes())); + given(converter.canRead(ResolvableType.forType(intList.getType()), MediaType.TEXT_PLAIN)).willReturn(true); + given(converter.read(eq(ResolvableType.forType(intList.getType())), any(HttpInputMessage.class), eq(null))).willReturn(expected); + + HttpHeaders entityHeaders = new HttpHeaders(); + entityHeaders.set("MyHeader", "MyValue"); + HttpEntity requestEntity = new HttpEntity<>("Hello World", entityHeaders); + ResponseEntity> result = template.exchange("https://example.com", POST, requestEntity, intList); + assertThat(result.getBody()).as("Invalid POST result").isEqualTo(expected); + assertThat(result.getHeaders().getContentType()).as("Invalid Content-Type").isEqualTo(MediaType.TEXT_PLAIN); + assertThat(requestHeaders.getFirst("Accept")).as("Invalid Accept header").isEqualTo(MediaType.TEXT_PLAIN_VALUE); + assertThat(requestHeaders.getFirst("MyHeader")).as("Invalid custom header").isEqualTo("MyValue"); + assertThat(result.getStatusCode()).as("Invalid status code").isEqualTo(HttpStatus.OK); + + verify(response).close(); + } + @Test // SPR-15066 void requestInterceptorCanAddExistingHeaderValueWithoutBody() throws Exception { ClientHttpRequestInterceptor interceptor = (request, body, execution) -> { @@ -768,7 +805,6 @@ private void mockResponseStatus(HttpStatus responseStatus) throws Exception { given(request.execute()).willReturn(response); given(errorHandler.hasError(response)).willReturn(responseStatus.isError()); given(response.getStatusCode()).willReturn(responseStatus); - given(response.getRawStatusCode()).willReturn(responseStatus.value()); given(response.getStatusText()).willReturn(responseStatus.getReasonPhrase()); } diff --git a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java index 78f8b331092c..2f5e87f985c7 100644 --- a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,6 @@ import java.util.stream.Stream; import io.micrometer.observation.tck.TestObservationRegistry; -import io.micrometer.observation.tck.TestObservationRegistryAssert; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; @@ -131,9 +130,8 @@ void greeting( assertThat(response).isEqualTo("Hello Spring!"); assertThat(request.getMethod()).isEqualTo("GET"); assertThat(request.getPath()).isEqualTo("/greeting"); - TestObservationRegistryAssert.assertThat(observationRegistry) - .hasObservationWithNameEqualTo("http.client.requests") - .that().hasLowCardinalityKeyValue("uri", "/greeting"); + assertThat(observationRegistry).hasObservationWithNameEqualTo("http.client.requests").that() + .hasLowCardinalityKeyValue("uri", "/greeting"); } @ParameterizedAdapterTest @@ -147,9 +145,8 @@ void greetingById( assertThat(response.getBody()).isEqualTo("Hello Spring!"); assertThat(request.getMethod()).isEqualTo("GET"); assertThat(request.getPath()).isEqualTo("/greeting/456"); - TestObservationRegistryAssert.assertThat(observationRegistry) - .hasObservationWithNameEqualTo("http.client.requests") - .that().hasLowCardinalityKeyValue("uri", "/greeting/{id}"); + assertThat(observationRegistry).hasObservationWithNameEqualTo("http.client.requests").that() + .hasLowCardinalityKeyValue("uri", "/greeting/{id}"); } @ParameterizedAdapterTest @@ -163,9 +160,8 @@ void greetingWithDynamicUri( assertThat(response.orElse("empty")).isEqualTo("Hello Spring!"); assertThat(request.getMethod()).isEqualTo("GET"); assertThat(request.getRequestUrl().uri()).isEqualTo(dynamicUri); - TestObservationRegistryAssert.assertThat(observationRegistry) - .hasObservationWithNameEqualTo("http.client.requests") - .that().hasLowCardinalityKeyValue("uri", "none"); + assertThat(observationRegistry).hasObservationWithNameEqualTo("http.client.requests").that() + .hasLowCardinalityKeyValue("uri", "none"); } @ParameterizedAdapterTest @@ -188,7 +184,7 @@ void formData(MockWebServer server, Service service) throws Exception { service.postForm(map); RecordedRequest request = server.takeRequest(); - assertThat(request.getHeaders().get("Content-Type")).isEqualTo("application/x-www-form-urlencoded;charset=UTF-8"); + assertThat(request.getHeaders().get("Content-Type")).isEqualTo("application/x-www-form-urlencoded"); assertThat(request.getBody().readUtf8()).isEqualTo("param1=value+1¶m2=value+2"); } diff --git a/spring-web/src/test/java/org/springframework/web/context/request/RequestAttributesThreadLocalAccessorTests.java b/spring-web/src/test/java/org/springframework/web/context/request/RequestAttributesThreadLocalAccessorTests.java new file mode 100644 index 000000000000..4ca330b0a843 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/context/request/RequestAttributesThreadLocalAccessorTests.java @@ -0,0 +1,143 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.context.request; + +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import io.micrometer.context.ContextRegistry; +import io.micrometer.context.ContextSnapshot; +import io.micrometer.context.ContextSnapshot.Scope; +import io.micrometer.context.ContextSnapshotFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.testfixture.servlet.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.mockito.Mockito.mock; +import static org.springframework.web.context.request.RequestAttributes.SCOPE_REQUEST; + +/** + * Tests for {@link RequestAttributesThreadLocalAccessor}. + * + * @author Tadaya Tsuyukubo + * @author Rossen Stoyanchev + */ +class RequestAttributesThreadLocalAccessorTests { + + private final ContextRegistry registry = + new ContextRegistry().registerThreadLocalAccessor(new RequestAttributesThreadLocalAccessor()); + + + private static Stream propagation() { + RequestAttributes previous = mock(); + RequestAttributes current = mock(); + return Stream.of(arguments(null, current), arguments(previous, current)); + } + + @ParameterizedTest + @MethodSource + @SuppressWarnings({ "try", "unused" }) + void propagation(RequestAttributes previousRequest, RequestAttributes currentRequest) throws Exception { + ContextSnapshot snapshot = getSnapshotFor(currentRequest); + + AtomicReference requestInScope = new AtomicReference<>(); + AtomicReference requestAfterScope = new AtomicReference<>(); + + Thread thread = new Thread(() -> { + RequestContextHolder.setRequestAttributes(previousRequest); + try (Scope scope = snapshot.setThreadLocals()) { + requestInScope.set(RequestContextHolder.getRequestAttributes()); + } + requestAfterScope.set(RequestContextHolder.getRequestAttributes()); + }); + + thread.start(); + thread.join(1000); + + assertThat(requestInScope).hasValueSatisfying(value -> assertThat(value).isSameAs(currentRequest)); + assertThat(requestAfterScope).hasValueSatisfying(value -> assertThat(value).isSameAs(previousRequest)); + } + + @Test + @SuppressWarnings("try") + void accessAfterRequestMarkedCompleted() { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(); + servletRequest.setAttribute("k1", "v1"); + servletRequest.setAttribute("k2", "v2"); + + ServletRequestAttributes attributes = new ServletRequestAttributes(servletRequest, new MockHttpServletResponse()); + ContextSnapshot snapshot = getSnapshotFor(attributes); + attributes.requestCompleted(); // REQUEST dispatch ends, async handling continues + + try (Scope scope = snapshot.setThreadLocals()) { + RequestAttributes current = RequestContextHolder.getRequestAttributes(); + assertThat(current).isNotNull(); + assertThat(current.getAttributeNames(SCOPE_REQUEST)).containsExactly("k1", "k2"); + assertThat(current.getAttribute("k1", SCOPE_REQUEST)).isEqualTo("v1"); + assertThat(current.getAttribute("k2", SCOPE_REQUEST)).isEqualTo("v2"); + assertThatIllegalStateException().isThrownBy(() -> current.setAttribute("k3", "v3", SCOPE_REQUEST)); + } + } + + @Test + @SuppressWarnings("try") + void accessBeforeRequestMarkedCompleted() { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(); + ServletRequestAttributes previous = new ServletRequestAttributes(servletRequest, new MockHttpServletResponse()); + + ContextSnapshot snapshot = getSnapshotFor(previous); + + RequestContextHolder.setRequestAttributes(previous); + try { + try (Scope scope = snapshot.setThreadLocals()) { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + assertThat(attributes).isNotNull(); + attributes.setAttribute("k1", "v1", SCOPE_REQUEST); + } + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + assertThat(attributes).isNotNull(); + attributes.setAttribute("k2", "v2", SCOPE_REQUEST); + } + finally { + RequestContextHolder.resetRequestAttributes(); + } + + assertThat(previous.getAttributeNames(SCOPE_REQUEST)).containsExactly("k1", "k2"); + assertThat(previous.getAttribute("k1", SCOPE_REQUEST)).isEqualTo("v1"); + assertThat(previous.getAttribute("k2", SCOPE_REQUEST)).isEqualTo("v2"); + } + + private ContextSnapshot getSnapshotFor(RequestAttributes request) { + RequestContextHolder.setRequestAttributes(request); + try { + return ContextSnapshotFactory.builder() + .contextRegistry(this.registry).clearMissing(true).build() + .captureAll(); + } + finally { + RequestContextHolder.resetRequestAttributes(); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/context/request/async/DeferredResultTests.java b/spring-web/src/test/java/org/springframework/web/context/request/async/DeferredResultTests.java index c5f632d60f1d..24621bd75099 100644 --- a/spring-web/src/test/java/org/springframework/web/context/request/async/DeferredResultTests.java +++ b/spring-web/src/test/java/org/springframework/web/context/request/async/DeferredResultTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -93,7 +93,7 @@ void onCompletion() throws Exception { DeferredResult result = new DeferredResult<>(); result.onCompletion(() -> sb.append("completion event")); - result.getInterceptor().afterCompletion(null, null); + result.getLifecycleInterceptor().afterCompletion(null, null); assertThat(result.isSetOrExpired()).isTrue(); assertThat(sb.toString()).isEqualTo("completion event"); @@ -109,7 +109,7 @@ void onTimeout() throws Exception { result.setResultHandler(handler); result.onTimeout(() -> sb.append("timeout event")); - result.getInterceptor().handleTimeout(null, null); + result.getLifecycleInterceptor().handleTimeout(null, null); assertThat(sb.toString()).isEqualTo("timeout event"); assertThat(result.setResult("hello")).as("Should not be able to set result a second time").isFalse(); @@ -127,7 +127,7 @@ void onError() throws Exception { Exception e = new Exception(); result.onError(t -> sb.append("error event")); - result.getInterceptor().handleError(null, null, e); + result.getLifecycleInterceptor().handleError(null, null, e); assertThat(sb.toString()).isEqualTo("error event"); assertThat(result.setResult("hello")).as("Should not be able to set result a second time").isFalse(); diff --git a/spring-web/src/test/java/org/springframework/web/context/request/async/WebAsyncManagerErrorTests.java b/spring-web/src/test/java/org/springframework/web/context/request/async/WebAsyncManagerErrorTests.java index 542638dcbf2c..7796e33ffa13 100644 --- a/spring-web/src/test/java/org/springframework/web/context/request/async/WebAsyncManagerErrorTests.java +++ b/spring-web/src/test/java/org/springframework/web/context/request/async/WebAsyncManagerErrorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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.web.context.request.async; +import java.io.IOException; import java.util.concurrent.Callable; import jakarta.servlet.AsyncEvent; @@ -152,6 +153,21 @@ void startCallableProcessingAfterException() throws Exception { verify(interceptor).beforeConcurrentHandling(this.asyncWebRequest, callable); } + @Test // gh-34363 + void startCallableProcessingDisconnectedClient() throws Exception { + StubCallable callable = new StubCallable(); + this.asyncManager.startCallableProcessing(callable); + + IOException ex = new IOException("broken pipe"); + AsyncEvent event = new AsyncEvent(new MockAsyncContext(this.servletRequest, this.servletResponse), ex); + this.asyncWebRequest.onError(event); + + assertThat(this.asyncManager.hasConcurrentResult()).isTrue(); + assertThat(this.asyncManager.getConcurrentResult()) + .as("Disconnected client error not wrapped in AsyncRequestNotUsableException") + .isExactlyInstanceOf(AsyncRequestNotUsableException.class); + } + @Test void startDeferredResultProcessingErrorAndComplete() throws Exception { @@ -259,6 +275,21 @@ public boolean handleError(NativeWebRequest request, DeferredResult resul assertThat(((MockAsyncContext) this.servletRequest.getAsyncContext()).getDispatchedPath()).isEqualTo("/test"); } + @Test // gh-34363 + void startDeferredResultProcessingDisconnectedClient() throws Exception { + DeferredResult deferredResult = new DeferredResult<>(); + this.asyncManager.startDeferredResultProcessing(deferredResult); + + IOException ex = new IOException("broken pipe"); + AsyncEvent event = new AsyncEvent(new MockAsyncContext(this.servletRequest, this.servletResponse), ex); + this.asyncWebRequest.onError(event); + + assertThat(this.asyncManager.hasConcurrentResult()).isTrue(); + assertThat(deferredResult.getResult()) + .as("Disconnected client error not wrapped in AsyncRequestNotUsableException") + .isExactlyInstanceOf(AsyncRequestNotUsableException.class); + } + private static final class StubCallable implements Callable { @Override diff --git a/spring-web/src/test/java/org/springframework/web/context/request/async/WebAsyncManagerTests.java b/spring-web/src/test/java/org/springframework/web/context/request/async/WebAsyncManagerTests.java index e0ddfa62108e..52a13d80cf31 100644 --- a/spring-web/src/test/java/org/springframework/web/context/request/async/WebAsyncManagerTests.java +++ b/spring-web/src/test/java/org/springframework/web/context/request/async/WebAsyncManagerTests.java @@ -72,7 +72,7 @@ void startAsyncProcessingWithoutAsyncWebRequest() { .withMessage("AsyncWebRequest must not be null"); assertThatIllegalStateException() - .isThrownBy(() -> manager.startDeferredResultProcessing(new DeferredResult())) + .isThrownBy(() -> manager.startDeferredResultProcessing(new DeferredResult<>())) .withMessage("AsyncWebRequest must not be null"); } diff --git a/spring-web/src/test/java/org/springframework/web/context/support/Spr8510Tests.java b/spring-web/src/test/java/org/springframework/web/context/support/Spr8510Tests.java index 8642352a20f0..0d8bb047fd80 100644 --- a/spring-web/src/test/java/org/springframework/web/context/support/Spr8510Tests.java +++ b/spring-web/src/test/java/org/springframework/web/context/support/Spr8510Tests.java @@ -29,7 +29,7 @@ /** * Tests the interaction between a WebApplicationContext and ContextLoaderListener with * regard to config location precedence, overriding and defaulting in programmatic - * configuration use cases, e.g. with WebApplicationInitializer. + * configuration use cases, for example, with WebApplicationInitializer. * * @author Chris Beams * @since 3.1 diff --git a/spring-web/src/test/java/org/springframework/web/filter/ServerHttpObservationFilterTests.java b/spring-web/src/test/java/org/springframework/web/filter/ServerHttpObservationFilterTests.java index ddbbad37cf4f..487727ac83d0 100644 --- a/spring-web/src/test/java/org/springframework/web/filter/ServerHttpObservationFilterTests.java +++ b/spring-web/src/test/java/org/springframework/web/filter/ServerHttpObservationFilterTests.java @@ -18,6 +18,7 @@ import java.io.IOException; +import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationRegistry; import io.micrometer.observation.tck.TestObservationRegistry; import io.micrometer.observation.tck.TestObservationRegistryAssert; @@ -38,6 +39,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.server.observation.ServerRequestObservationContext; +import org.springframework.util.Assert; import org.springframework.web.testfixture.servlet.MockAsyncContext; import org.springframework.web.testfixture.servlet.MockFilterChain; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; @@ -90,7 +92,7 @@ void filterShouldOpenScope() throws Exception { @Test void filterShouldAcceptNoOpObservationContext() throws Exception { - ServerHttpObservationFilter filter = new ServerHttpObservationFilter(ObservationRegistry.NOOP); + this.filter = new ServerHttpObservationFilter(ObservationRegistry.NOOP); filter.doFilter(this.request, this.response, this.mockFilterChain); ServerRequestObservationContext context = (ServerRequestObservationContext) this.request @@ -134,6 +136,14 @@ void filterShouldSetDefaultErrorStatusForBubblingExceptions() { .hasLowCardinalityKeyValue("status", "500"); } + @Test + void customFilterShouldCallScopeOpened() throws Exception { + this.filter = new CustomObservationFilter(this.observationRegistry); + this.filter.doFilter(this.request, this.response, this.mockFilterChain); + + assertThat(this.response.getHeader("X-Trace-Id")).isEqualTo("badc0ff33"); + } + @Test void shouldCloseObservationAfterAsyncCompletion() throws Exception { this.request.setAsyncSupported(true); @@ -177,18 +187,14 @@ void shouldNotCloseObservationDuringAsyncDispatch() throws Exception { this.mockFilterChain = new MockFilterChain(new ScopeCheckingServlet(this.observationRegistry)); this.request.setDispatcherType(DispatcherType.ASYNC); this.filter.doFilter(this.request, this.response, this.mockFilterChain); - TestObservationRegistryAssert.assertThat(this.observationRegistry) - .hasObservationWithNameEqualTo("http.server.requests") - .that().isNotStopped(); + assertThat(this.observationRegistry).hasObservationWithNameEqualTo("http.server.requests").that() + .isNotStopped(); } private TestObservationRegistryAssert.TestObservationRegistryAssertReturningObservationContextAssert assertThatHttpObservation() { - TestObservationRegistryAssert.assertThat(this.observationRegistry) - .hasNumberOfObservationsWithNameEqualTo("http.server.requests", 1); + assertThat(this.observationRegistry).hasNumberOfObservationsWithNameEqualTo("http.server.requests", 1); - return TestObservationRegistryAssert.assertThat(this.observationRegistry) - .hasObservationWithNameEqualTo("http.server.requests") - .that() + return assertThat(this.observationRegistry).hasObservationWithNameEqualTo("http.server.requests").that() .hasBeenStopped(); } @@ -207,6 +213,22 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws Se } } + static class CustomObservationFilter extends ServerHttpObservationFilter { + + public CustomObservationFilter(ObservationRegistry observationRegistry) { + super(observationRegistry); + } + + @Override + protected void onScopeOpened(Observation.Scope scope, HttpServletRequest request, HttpServletResponse response) { + Assert.notNull(scope, "scope must not be null"); + Assert.notNull(request, "request must not be null"); + Assert.notNull(response, "response must not be null"); + response.setHeader("X-Trace-Id", "badc0ff33"); + } + + } + @SuppressWarnings("serial") static class NoOpServlet extends HttpServlet { diff --git a/spring-web/src/test/java/org/springframework/web/filter/UrlHandlerFilterTests.java b/spring-web/src/test/java/org/springframework/web/filter/UrlHandlerFilterTests.java new file mode 100644 index 000000000000..41a4f215551b --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/filter/UrlHandlerFilterTests.java @@ -0,0 +1,127 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.filter; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; +import org.springframework.web.testfixture.servlet.MockFilterChain; +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.testfixture.servlet.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link UrlHandlerFilter}. + * + * @author Rossen Stoyanchev + */ +public class UrlHandlerFilterTests { + + @Test + void requestWrapping() throws Exception { + testRequestWrapping("/path/**", "/path/123", null); + testRequestWrapping("/path/*", "/path", "/123"); + testRequestWrapping("/path/*", "", "/path/123"); + } + + void testRequestWrapping(String pattern, String servletPath, @Nullable String pathInfo) throws Exception { + + UrlHandlerFilter filter = UrlHandlerFilter.trailingSlashHandler(pattern).wrapRequest().build(); + + boolean hasPathInfo = StringUtils.hasLength(pathInfo); + String requestURI = servletPath + (hasPathInfo ? pathInfo : ""); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestURI + "/"); + request.setServletPath(hasPathInfo ? servletPath : servletPath + "/"); + request.setPathInfo(hasPathInfo ? pathInfo + "/" : pathInfo); + + MockFilterChain chain = new MockFilterChain(); + filter.doFilterInternal(request, new MockHttpServletResponse(), chain); + + HttpServletRequest actual = (HttpServletRequest) chain.getRequest(); + assertThat(actual).isNotNull().isNotSameAs(request); + assertThat(actual.getRequestURI()).isEqualTo(requestURI); + assertThat(actual.getRequestURL().toString()).isEqualTo("http://localhost" + requestURI); + assertThat(actual.getServletPath()).isEqualTo(servletPath); + assertThat(actual.getPathInfo()).isEqualTo(pathInfo); + } + + @Test + void redirect() throws Exception { + HttpStatus status = HttpStatus.PERMANENT_REDIRECT; + UrlHandlerFilter filter = UrlHandlerFilter.trailingSlashHandler("/path/*").redirect(status).build(); + + String path = "/path/123"; + MockHttpServletResponse response = new MockHttpServletResponse(); + + MockFilterChain chain = new MockFilterChain(); + filter.doFilterInternal(new MockHttpServletRequest("GET", path + "/"), response, chain); + + assertThat(chain.getRequest()).isNull(); + assertThat(response.getStatus()).isEqualTo(status.value()); + assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo(path); + assertThat(response.isCommitted()).isTrue(); + } + + @Test + void noUrlHandling() throws Exception { + testNoUrlHandling("/path/**", "", "/path/123"); + testNoUrlHandling("/path/*", "", "/path/123"); + testNoUrlHandling("/**", "", "/"); // gh-33444 + testNoUrlHandling("/**", "/myApp", "/myApp/"); // gh-33565 + } + + private static void testNoUrlHandling(String pattern, String contextPath, String requestURI) + throws ServletException, IOException { + + // No request wrapping + UrlHandlerFilter filter = UrlHandlerFilter.trailingSlashHandler(pattern).wrapRequest().build(); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestURI); + request.setContextPath(contextPath); + MockFilterChain chain = new MockFilterChain(); + filter.doFilterInternal(request, new MockHttpServletResponse(), chain); + + HttpServletRequest actual = (HttpServletRequest) chain.getRequest(); + assertThat(actual).as("Request should not be wrapped").isSameAs(request); + + // No redirect + HttpStatus status = HttpStatus.PERMANENT_REDIRECT; + filter = UrlHandlerFilter.trailingSlashHandler(pattern).redirect(status).build(); + + request = new MockHttpServletRequest("GET", requestURI); + request.setContextPath(contextPath); + MockHttpServletResponse response = new MockHttpServletResponse(); + + chain = new MockFilterChain(); + filter.doFilterInternal(request, response, chain); + + assertThat(chain.getRequest()).isSameAs(request); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeader(HttpHeaders.LOCATION)).isNull(); + assertThat(response.isCommitted()).isFalse(); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/filter/reactive/ServerHttpObservationFilterTests.java b/spring-web/src/test/java/org/springframework/web/filter/reactive/ServerHttpObservationFilterTests.java index fc1d3e75f7ce..89e15eef5e9a 100644 --- a/spring-web/src/test/java/org/springframework/web/filter/reactive/ServerHttpObservationFilterTests.java +++ b/spring-web/src/test/java/org/springframework/web/filter/reactive/ServerHttpObservationFilterTests.java @@ -137,7 +137,6 @@ private WebFilterChain createFilterChain(ThrowingConsumer exc } private TestObservationRegistryAssert.TestObservationRegistryAssertReturningObservationContextAssert assertThatHttpObservation() { - return TestObservationRegistryAssert.assertThat(this.observationRegistry) - .hasObservationWithNameEqualTo("http.server.requests").that(); + return assertThat(this.observationRegistry).hasObservationWithNameEqualTo("http.server.requests").that(); } } diff --git a/spring-web/src/test/java/org/springframework/web/filter/reactive/UrlHandlerFilterTests.java b/spring-web/src/test/java/org/springframework/web/filter/reactive/UrlHandlerFilterTests.java new file mode 100644 index 000000000000..d3288302f7ec --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/filter/reactive/UrlHandlerFilterTests.java @@ -0,0 +1,123 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.filter.reactive; + +import java.net.URI; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.handler.DefaultWebFilterChain; +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link UrlHandlerFilter}. + * + * @author Rossen Stoyanchev + */ +public class UrlHandlerFilterTests { + + @Test + void requestMutation() { + UrlHandlerFilter filter = UrlHandlerFilter.trailingSlashHandler("/path/**").mutateRequest().build(); + + String path = "/path/123"; + MockServerHttpRequest original = MockServerHttpRequest.get(path + "/").build(); + ServerWebExchange exchange = MockServerWebExchange.from(original); + + ServerHttpRequest actual = invokeFilter(filter, exchange); + + assertThat(actual).isNotNull().isNotSameAs(original); + assertThat(actual.getPath().value()).isEqualTo(path); + } + + @Test + void redirect() { + HttpStatus status = HttpStatus.PERMANENT_REDIRECT; + UrlHandlerFilter filter = UrlHandlerFilter.trailingSlashHandler("/path/*").redirect(status).build(); + + String path = "/path/123"; + MockServerHttpRequest original = MockServerHttpRequest.get(path + "/").build(); + ServerWebExchange exchange = MockServerWebExchange.from(original); + + assertThatThrownBy(() -> invokeFilter(filter, exchange)) + .hasMessageContaining("No argument value was captured"); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(status); + assertThat(exchange.getResponse().getHeaders().getLocation()).isEqualTo(URI.create(path)); + } + + @Test + void noUrlHandling() { + testNoUrlHandling("/path/**", "", "/path/123"); + testNoUrlHandling("/path/*", "", "/path/123"); + testNoUrlHandling("/**", "", "/"); // gh-33444 + testNoUrlHandling("/**", "/myApp", "/myApp/"); // gh-33565 + } + + private static void testNoUrlHandling(String pattern, String contextPath, String path) { + + // No request mutation + UrlHandlerFilter filter = UrlHandlerFilter.trailingSlashHandler(pattern).mutateRequest().build(); + + MockServerHttpRequest request = MockServerHttpRequest.get(path).contextPath(contextPath).build(); + ServerWebExchange exchange = MockServerWebExchange.from(request); + + ServerHttpRequest actual = invokeFilter(filter, exchange); + + assertThat(actual).isNotNull().isSameAs(request); + assertThat(actual.getPath().value()).isEqualTo(path); + + // No redirect + HttpStatus status = HttpStatus.PERMANENT_REDIRECT; + filter = UrlHandlerFilter.trailingSlashHandler(pattern).redirect(status).build(); + + request = MockServerHttpRequest.get(path).contextPath(contextPath).build(); + exchange = MockServerWebExchange.from(request); + + actual = invokeFilter(filter, exchange); + + assertThat(actual).isNotNull().isSameAs(request); + assertThat(exchange.getResponse().getStatusCode()).isNull(); + assertThat(exchange.getResponse().getHeaders().getLocation()).isNull(); + } + + private static ServerHttpRequest invokeFilter(UrlHandlerFilter filter, ServerWebExchange exchange) { + WebHandler handler = mock(WebHandler.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(ServerWebExchange.class); + given(handler.handle(captor.capture())).willReturn(Mono.empty()); + + WebFilterChain chain = new DefaultWebFilterChain(handler, List.of(filter)); + filter.filter(exchange, chain).block(); + + return captor.getValue().getRequest(); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/method/ControllerAdviceBeanTests.java b/spring-web/src/test/java/org/springframework/web/method/ControllerAdviceBeanTests.java index b04f5c22a961..723a23972753 100644 --- a/spring-web/src/test/java/org/springframework/web/method/ControllerAdviceBeanTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/ControllerAdviceBeanTests.java @@ -23,22 +23,23 @@ import jakarta.annotation.Priority; import org.junit.jupiter.api.Test; -import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.BeanFactory; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.StaticApplicationContext; import org.springframework.core.Ordered; import org.springframework.core.PriorityOrdered; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.Order; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.RestController; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; /** * Unit and integration tests for {@link ControllerAdviceBean}. @@ -48,89 +49,91 @@ */ class ControllerAdviceBeanTests { - @Test - void constructorPreconditions() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new ControllerAdviceBean(null)) - .withMessage("Bean must not be null"); - - assertThatIllegalArgumentException() - .isThrownBy(() -> new ControllerAdviceBean(null, null)) - .withMessage("Bean name must contain text"); + private StaticApplicationContext applicationContext = new StaticApplicationContext(); + @Test + void shouldFailForNullOrEmptyBeanName() { assertThatIllegalArgumentException() - .isThrownBy(() -> new ControllerAdviceBean("", null)) - .withMessage("Bean name must contain text"); + .isThrownBy(() -> new ControllerAdviceBean(null, null, null)) + .withMessage("Bean name must contain text"); assertThatIllegalArgumentException() - .isThrownBy(() -> new ControllerAdviceBean("\t", null)) - .withMessage("Bean name must contain text"); + .isThrownBy(() -> new ControllerAdviceBean(" ", null, null)) + .withMessage("Bean name must contain text"); + } + @Test + void shouldFailForNullBeanFactory() { assertThatIllegalArgumentException() - .isThrownBy(() -> new ControllerAdviceBean("myBean", null)) - .withMessage("BeanFactory must not be null"); + .isThrownBy(() -> new ControllerAdviceBean("beanName", null, null)) + .withMessage("BeanFactory must not be null"); } @Test - void equalsHashCodeAndToStringForBeanName() { - String beanName = "myBean"; - BeanFactory beanFactory = mock(); - given(beanFactory.containsBean(beanName)).willReturn(true); - - ControllerAdviceBean bean1 = new ControllerAdviceBean(beanName, beanFactory); - ControllerAdviceBean bean2 = new ControllerAdviceBean(beanName, beanFactory); - assertEqualsHashCodeAndToString(bean1, bean2, beanName); + void shouldFailWhenBeanFactoryDoesNotContainBean() { + BeanFactory beanFactory = mock(BeanFactory.class); + given(beanFactory.containsBean(eq("beanName"))).willReturn(false); + assertThatIllegalArgumentException() + .isThrownBy(() -> new ControllerAdviceBean("beanName", beanFactory, null)) + .withMessageContaining("does not contain specified controller advice bean 'beanName'"); } @Test - void equalsHashCodeAndToStringForBeanInstance() { - String toString = "beanInstance"; - Object beanInstance = new Object() { - @Override - public String toString() { - return toString; - } - }; - ControllerAdviceBean bean1 = new ControllerAdviceBean(beanInstance); - ControllerAdviceBean bean2 = new ControllerAdviceBean(beanInstance); - assertEqualsHashCodeAndToString(bean1, bean2, toString); + void shouldFailWhenControllerAdviceNull() { + BeanFactory beanFactory = mock(BeanFactory.class); + given(beanFactory.containsBean(eq("beanName"))).willReturn(true); + assertThatIllegalArgumentException() + .isThrownBy(() -> new ControllerAdviceBean("beanName", beanFactory, null)) + .withMessage("ControllerAdvice must not be null"); } @Test - void orderedWithLowestPrecedenceByDefaultForBeanName() { - assertOrder(SimpleControllerAdvice.class, Ordered.LOWEST_PRECEDENCE); + void equalsHashCodeAndToString() { + String beanName = SimpleControllerAdvice.class.getSimpleName(); + ControllerAdviceBean bean1 = createSingletonControllerAdviceBean(SimpleControllerAdvice.class); + ControllerAdviceBean bean2 = createSingletonControllerAdviceBean(SimpleControllerAdvice.class); + assertEqualsHashCodeAndToString(bean1, bean2, beanName); } @Test - void orderedWithLowestPrecedenceByDefaultForBeanInstance() { - assertOrder(new SimpleControllerAdvice(), Ordered.LOWEST_PRECEDENCE); + void orderedWithLowestPrecedenceByDefault() { + assertOrder(SimpleControllerAdvice.class, Ordered.LOWEST_PRECEDENCE); } @Test - void orderedViaOrderedInterfaceForBeanName() { + void orderedViaOrderedInterface() { assertOrder(OrderedControllerAdvice.class, 42); } @Test - void orderedViaOrderedInterfaceForBeanInstance() { - assertOrder(new OrderedControllerAdvice(), 42); + void orderedViaAnnotation() { + assertOrder(OrderAnnotationControllerAdvice.class, 100); + assertOrder(PriorityAnnotationControllerAdvice.class, 200); } @Test - void orderedViaAnnotationForBeanName() { - assertOrder(OrderAnnotationControllerAdvice.class, 100); - assertOrder(PriorityAnnotationControllerAdvice.class, 200); + void resolveBeanForSingletonBean() { + String beanName = SimpleControllerAdvice.class.getSimpleName(); + ControllerAdviceBean cab = createSingletonControllerAdviceBean(SimpleControllerAdvice.class); + Object bean = this.applicationContext.getBean(beanName); + assertThat(cab).extracting("resolvedBean").isNull(); + Object resolvedBean = cab.resolveBean(); + assertThat(cab).extracting("resolvedBean").isEqualTo(bean); + assertThat(resolvedBean).isEqualTo(bean); } @Test - void orderedViaAnnotationForBeanInstance() { - assertOrder(new OrderAnnotationControllerAdvice(), 100); - assertOrder(new PriorityAnnotationControllerAdvice(), 200); + void resolveBeanForPrototypeBean() { + ControllerAdviceBean cab = createPrototypeControllerAdviceBean(SimpleControllerAdvice.class); + assertThat(cab).extracting("resolvedBean").isNull(); + Object resolvedBean = cab.resolveBean(); + assertThat(cab).extracting("resolvedBean").isNull(); + assertThat(resolvedBean).isInstanceOf(SimpleControllerAdvice.class); } @Test void shouldMatchAll() { - ControllerAdviceBean bean = new ControllerAdviceBean(new SimpleControllerAdvice()); + ControllerAdviceBean bean = createSingletonControllerAdviceBean(SimpleControllerAdvice.class); assertApplicable("should match all", bean, AnnotatedController.class); assertApplicable("should match all", bean, ImplementationController.class); assertApplicable("should match all", bean, InheritanceController.class); @@ -139,7 +142,7 @@ void shouldMatchAll() { @Test void basePackageSupport() { - ControllerAdviceBean bean = new ControllerAdviceBean(new BasePackageSupport()); + ControllerAdviceBean bean = createSingletonControllerAdviceBean(BasePackageSupport.class); assertApplicable("base package support", bean, AnnotatedController.class); assertApplicable("base package support", bean, ImplementationController.class); assertApplicable("base package support", bean, InheritanceController.class); @@ -148,7 +151,7 @@ void basePackageSupport() { @Test void basePackageValueSupport() { - ControllerAdviceBean bean = new ControllerAdviceBean(new BasePackageValueSupport()); + ControllerAdviceBean bean = createSingletonControllerAdviceBean(BasePackageValueSupport.class); assertApplicable("base package support", bean, AnnotatedController.class); assertApplicable("base package support", bean, ImplementationController.class); assertApplicable("base package support", bean, InheritanceController.class); @@ -157,14 +160,14 @@ void basePackageValueSupport() { @Test void annotationSupport() { - ControllerAdviceBean bean = new ControllerAdviceBean(new AnnotationSupport()); + ControllerAdviceBean bean = createSingletonControllerAdviceBean(AnnotationSupport.class); assertApplicable("annotation support", bean, AnnotatedController.class); assertNotApplicable("this bean is not annotated", bean, InheritanceController.class); } @Test void markerClassSupport() { - ControllerAdviceBean bean = new ControllerAdviceBean(new MarkerClassSupport()); + ControllerAdviceBean bean = createSingletonControllerAdviceBean(MarkerClassSupport.class); assertApplicable("base package class support", bean, AnnotatedController.class); assertApplicable("base package class support", bean, ImplementationController.class); assertApplicable("base package class support", bean, InheritanceController.class); @@ -173,7 +176,7 @@ void markerClassSupport() { @Test void shouldNotMatch() { - ControllerAdviceBean bean = new ControllerAdviceBean(new ShouldNotMatch()); + ControllerAdviceBean bean = createSingletonControllerAdviceBean(ShouldNotMatch.class); assertNotApplicable("should not match", bean, AnnotatedController.class); assertNotApplicable("should not match", bean, ImplementationController.class); assertNotApplicable("should not match", bean, InheritanceController.class); @@ -182,7 +185,7 @@ void shouldNotMatch() { @Test void assignableTypesSupport() { - ControllerAdviceBean bean = new ControllerAdviceBean(new AssignableTypesSupport()); + ControllerAdviceBean bean = createSingletonControllerAdviceBean(AssignableTypesSupport.class); assertApplicable("controller implements assignable", bean, ImplementationController.class); assertApplicable("controller inherits assignable", bean, InheritanceController.class); assertNotApplicable("not assignable", bean, AnnotatedController.class); @@ -191,7 +194,7 @@ void assignableTypesSupport() { @Test void multipleMatch() { - ControllerAdviceBean bean = new ControllerAdviceBean(new MultipleSelectorsSupport()); + ControllerAdviceBean bean = createSingletonControllerAdviceBean(MultipleSelectorsSupport.class); assertApplicable("controller implements assignable", bean, ImplementationController.class); assertApplicable("controller is annotated", bean, AnnotatedController.class); assertNotApplicable("should not match", bean, InheritanceController.class); @@ -201,14 +204,14 @@ void multipleMatch() { @SuppressWarnings({"rawtypes", "unchecked"}) public void findAnnotatedBeansSortsBeans() { Class[] expectedTypes = { - // Since ControllerAdviceBean currently treats PriorityOrdered the same as Ordered, - // OrderedControllerAdvice is sorted before PriorityOrderedControllerAdvice. - OrderedControllerAdvice.class, - PriorityOrderedControllerAdvice.class, - OrderAnnotationControllerAdvice.class, - PriorityAnnotationControllerAdvice.class, - SimpleControllerAdviceWithBeanOrder.class, - SimpleControllerAdvice.class, + // Since ControllerAdviceBean currently treats PriorityOrdered the same as Ordered, + // OrderedControllerAdvice is sorted before PriorityOrderedControllerAdvice. + OrderedControllerAdvice.class, + PriorityOrderedControllerAdvice.class, + OrderAnnotationControllerAdvice.class, + PriorityAnnotationControllerAdvice.class, + SimpleControllerAdviceWithBeanOrder.class, + SimpleControllerAdvice.class, }; AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class); @@ -217,6 +220,20 @@ public void findAnnotatedBeansSortsBeans() { assertThat(adviceBeans).extracting(ControllerAdviceBean::getBeanType).containsExactly(expectedTypes); } + private ControllerAdviceBean createSingletonControllerAdviceBean(Class beanType) { + String beanName = beanType.getSimpleName(); + this.applicationContext.registerSingleton(beanName, beanType); + ControllerAdvice controllerAdvice = AnnotatedElementUtils.findMergedAnnotation(beanType, ControllerAdvice.class); + return new ControllerAdviceBean(beanName, this.applicationContext, controllerAdvice); + } + + private ControllerAdviceBean createPrototypeControllerAdviceBean(Class beanType) { + String beanName = beanType.getSimpleName(); + this.applicationContext.registerPrototype(beanName, beanType); + ControllerAdvice controllerAdvice = AnnotatedElementUtils.findMergedAnnotation(beanType, ControllerAdvice.class); + return new ControllerAdviceBean(beanName, this.applicationContext, controllerAdvice); + } + private void assertEqualsHashCodeAndToString(ControllerAdviceBean bean1, ControllerAdviceBean bean2, String toString) { assertThat(bean1).isEqualTo(bean2); assertThat(bean2).isEqualTo(bean1); @@ -225,24 +242,8 @@ private void assertEqualsHashCodeAndToString(ControllerAdviceBean bean1, Control assertThat(bean2.toString()).isEqualTo(toString); } - private void assertOrder(Object bean, int expectedOrder) { - assertThat(new ControllerAdviceBean(bean).getOrder()).isEqualTo(expectedOrder); - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - private void assertOrder(Class beanType, int expectedOrder) { - String beanName = "myBean"; - BeanFactory beanFactory = mock(); - given(beanFactory.containsBean(beanName)).willReturn(true); - given(beanFactory.getType(beanName)).willReturn(beanType); - given(beanFactory.getBean(beanName)).willReturn(BeanUtils.instantiateClass(beanType)); - - ControllerAdviceBean controllerAdviceBean = new ControllerAdviceBean(beanName, beanFactory); - - assertThat(controllerAdviceBean.getOrder()).isEqualTo(expectedOrder); - verify(beanFactory).containsBean(beanName); - verify(beanFactory).getType(beanName); - verify(beanFactory).getBean(beanName); + private void assertOrder(Class beanType, int expectedOrder) { + assertThat(createSingletonControllerAdviceBean(beanType).getOrder()).isEqualTo(expectedOrder); } private void assertApplicable(String message, ControllerAdviceBean controllerAdvice, Class controllerBeanType) { @@ -259,18 +260,22 @@ private void assertNotApplicable(String message, ControllerAdviceBean controller // ControllerAdvice classes @ControllerAdvice - static class SimpleControllerAdvice {} + static class SimpleControllerAdvice { + } @ControllerAdvice - static class SimpleControllerAdviceWithBeanOrder {} + static class SimpleControllerAdviceWithBeanOrder { + } @ControllerAdvice @Order(100) - static class OrderAnnotationControllerAdvice {} + static class OrderAnnotationControllerAdvice { + } @ControllerAdvice @Priority(200) - static class PriorityAnnotationControllerAdvice {} + static class PriorityAnnotationControllerAdvice { + } @ControllerAdvice // @Order and @Priority should be ignored due to implementation of Ordered. @@ -297,45 +302,59 @@ public int getOrder() { } @ControllerAdvice(annotations = ControllerAnnotation.class) - static class AnnotationSupport {} + static class AnnotationSupport { + } @ControllerAdvice(basePackageClasses = MarkerClass.class) - static class MarkerClassSupport {} + static class MarkerClassSupport { + } @ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class}) - static class AssignableTypesSupport {} + static class AssignableTypesSupport { + } @ControllerAdvice(basePackages = "org.springframework.web.method") - static class BasePackageSupport {} + static class BasePackageSupport { + } @ControllerAdvice("org.springframework.web.method") - static class BasePackageValueSupport {} + static class BasePackageValueSupport { + } @ControllerAdvice(annotations = ControllerAnnotation.class, assignableTypes = ControllerInterface.class) - static class MultipleSelectorsSupport {} + static class MultipleSelectorsSupport { + } @ControllerAdvice(basePackages = "java.util", annotations = {RestController.class}) - static class ShouldNotMatch {} + static class ShouldNotMatch { + } // Support classes - static class MarkerClass {} + static class MarkerClass { + } @Retention(RetentionPolicy.RUNTIME) - @interface ControllerAnnotation {} + @interface ControllerAnnotation { + } @ControllerAnnotation - public static class AnnotatedController {} + public static class AnnotatedController { + } - interface ControllerInterface {} + interface ControllerInterface { + } - static class ImplementationController implements ControllerInterface {} + static class ImplementationController implements ControllerInterface { + } - abstract static class AbstractController {} + abstract static class AbstractController { + } - static class InheritanceController extends AbstractController {} + static class InheritanceController extends AbstractController { + } @Configuration(proxyBeanMethods = false) static class Config { diff --git a/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java b/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java index 678b9587f658..8d1f825028c8 100644 --- a/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,6 +25,7 @@ import jakarta.validation.constraints.Size; import org.junit.jupiter.api.Test; +import org.springframework.context.support.StaticApplicationContext; import org.springframework.util.ClassUtils; import org.springframework.validation.annotation.Validated; @@ -72,6 +73,30 @@ void classLevelValidatedAnnotation() { testValidateReturnValue(target, List.of("getPerson"), false); } + @Test // gh-34277 + void createWithResolvedBeanSameInstance() { + MyClass target = new MyClass(); + HandlerMethod handlerMethod = getHandlerMethod(target, "addPerson"); + assertThat(handlerMethod.createWithResolvedBean()).isSameAs(handlerMethod); + } + + @Test + void resolvedFromHandlerMethod() { + StaticApplicationContext context = new StaticApplicationContext(); + context.registerSingleton("myClass", MyClass.class); + + MyClass target = new MyClass(); + Method method = ClassUtils.getMethod(target.getClass(), "addPerson", (Class[]) null); + + HandlerMethod hm1 = new HandlerMethod("myClass", context.getBeanFactory(), method); + HandlerMethod hm2 = hm1.createWithValidateFlags(); + HandlerMethod hm3 = hm2.createWithResolvedBean(); + + assertThat(hm1.getResolvedFromHandlerMethod()).isNull(); + assertThat(hm2.getResolvedFromHandlerMethod()).isSameAs(hm1); + assertThat(hm3.getResolvedFromHandlerMethod()).isSameAs(hm1); + } + private static void testValidateArgs(Object target, List methodNames, boolean expected) { for (String methodName : methodNames) { assertThat(getHandlerMethod(target, methodName).shouldValidateArguments()).isEqualTo(expected); diff --git a/spring-web/src/test/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolverTests.java b/spring-web/src/test/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolverTests.java index eeccbd1ca68f..d70dc9f7d519 100644 --- a/spring-web/src/test/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolverTests.java @@ -25,6 +25,7 @@ import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.util.ClassUtils; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -33,28 +34,29 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** - * Test fixture for {@link ExceptionHandlerMethodResolver} tests. + * Tests for {@link ExceptionHandlerMethodResolver}. * * @author Rossen Stoyanchev + * @author Brian Clozel */ class ExceptionHandlerMethodResolverTests { @Test - void resolveMethodFromAnnotation() { + void shouldResolveMethodFromAnnotationAttribute() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class); IOException exception = new IOException(); assertThat(resolver.resolveMethod(exception).getName()).isEqualTo("handleIOException"); } @Test - void resolveMethodFromArgument() { + void shouldResolveMethodFromMethodArgument() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class); IllegalArgumentException exception = new IllegalArgumentException(); assertThat(resolver.resolveMethod(exception).getName()).isEqualTo("handleIllegalArgumentException"); } @Test - void resolveMethodExceptionSubType() { + void shouldResolveMethodWithExceptionSubType() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class); IOException ioException = new FileNotFoundException(); assertThat(resolver.resolveMethod(ioException).getName()).isEqualTo("handleIOException"); @@ -63,14 +65,14 @@ void resolveMethodExceptionSubType() { } @Test - void resolveMethodBestMatch() { + void shouldResolveMethodWithExceptionBestMatch() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class); SocketException exception = new SocketException(); assertThat(resolver.resolveMethod(exception).getName()).isEqualTo("handleSocketException"); } @Test - void resolveMethodNoMatch() { + void shouldNotResolveMethodWhenExceptionNoMatch() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class); Exception exception = new Exception(); assertThat(resolver.resolveMethod(exception)).as("1st lookup").isNull(); @@ -78,7 +80,7 @@ void resolveMethodNoMatch() { } @Test - void resolveMethodExceptionCause() { + void ShouldResolveMethodWithExceptionCause() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class); SocketException bindException = new BindException(); @@ -90,24 +92,65 @@ void resolveMethodExceptionCause() { } @Test - void resolveMethodInherited() { + void shouldResolveMethodFromSuperClass() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(InheritedController.class); IOException exception = new IOException(); assertThat(resolver.resolveMethod(exception).getName()).isEqualTo("handleIOException"); } @Test - void ambiguousExceptionMapping() { + void shouldThrowExceptionWhenAmbiguousExceptionMapping() { assertThatIllegalStateException().isThrownBy(() -> new ExceptionHandlerMethodResolver(AmbiguousController.class)); } @Test - void noExceptionMapping() { + void shouldThrowExceptionWhenNoExceptionMapping() { assertThatIllegalStateException().isThrownBy(() -> new ExceptionHandlerMethodResolver(NoExceptionController.class)); } + @Test + void shouldResolveMethodWithMediaType() { + ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(MediaTypeController.class); + assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.APPLICATION_JSON).getHandlerMethod().getName()).isEqualTo("handleJson"); + assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.TEXT_HTML).getHandlerMethod().getName()).isEqualTo("handleHtml"); + } + + @Test + void shouldResolveMethodWithCompatibleMediaType() { + ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(MediaTypeController.class); + assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.parseMediaType("application/*")).getHandlerMethod().getName()).isEqualTo("handleJson"); + } + + @Test + void shouldFavorMethodWithExplicitAcceptAll() { + ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(MediaTypeController.class); + assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.ALL).getHandlerMethod().getName()).isEqualTo("handleHtml"); + } + + @Test + void shouldThrowExceptionWhenInvalidMediaTypeMapping() { + assertThatIllegalStateException().isThrownBy(() -> + new ExceptionHandlerMethodResolver(InvalidMediaTypeController.class)) + .withMessageContaining("Invalid media type [invalid-mediatype] declared on @ExceptionHandler"); + } + + @Test + void shouldThrowExceptionWhenAmbiguousMediaTypeMapping() { + assertThatIllegalStateException().isThrownBy(() -> + new ExceptionHandlerMethodResolver(AmbiguousMediaTypeController.class)) + .withMessageContaining("Ambiguous @ExceptionHandler method mapped for [ExceptionHandler{exceptionType=java.lang.IllegalArgumentException, mediaType=application/json}]") + .withMessageContaining("AmbiguousMediaTypeController.handleJson()") + .withMessageContaining("AmbiguousMediaTypeController.handleJsonToo()"); + } + + @Test + void shouldResolveMethodWithMediaTypeFallback() { + ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(MixedController.class); + assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.TEXT_HTML).getHandlerMethod().getName()).isEqualTo("handleOther"); + } + @Controller static class ExceptionController { @@ -162,4 +205,58 @@ public void handle() { } } + @Controller + static class MediaTypeController { + + @ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "application/json") + public void handleJson() { + + } + + @ExceptionHandler(exception = {IllegalArgumentException.class}, produces = {"text/html", "*/*"}) + public void handleHtml() { + + } + + } + + @Controller + static class AmbiguousMediaTypeController { + + @ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "application/json") + public void handleJson() { + + } + + @ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "application/json") + public void handleJsonToo() { + + } + + } + + @Controller + static class MixedController { + + @ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "application/json") + public void handleJson() { + + } + + @ExceptionHandler(IllegalArgumentException.class) + public void handleOther() { + + } + + } + + @Controller + static class InvalidMediaTypeController { + + @ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "invalid-mediatype") + public void handle() { + + } + } + } diff --git a/spring-web/src/test/java/org/springframework/web/method/annotation/MapMethodProcessorTests.java b/spring-web/src/test/java/org/springframework/web/method/annotation/MapMethodProcessorTests.java index 483f88d80f3f..2f7c40dec9e2 100644 --- a/spring-web/src/test/java/org/springframework/web/method/annotation/MapMethodProcessorTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/annotation/MapMethodProcessorTests.java @@ -16,6 +16,7 @@ package org.springframework.web.method.annotation; +import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.BeforeEach; @@ -63,8 +64,13 @@ void setUp() { void supportsParameter() { assertThat(this.processor.supportsParameter( this.resolvable.annotNotPresent().arg(Map.class, String.class, Object.class))).isTrue(); + assertThat(this.processor.supportsParameter( this.resolvable.annotPresent(RequestBody.class).arg(Map.class, String.class, Object.class))).isFalse(); + + // gh-33160 + assertThat(this.processor.supportsParameter( + ResolvableMethod.on(getClass()).argTypes(ExtendedMap.class).build().arg(ExtendedMap.class))).isFalse(); } @Test @@ -100,4 +106,15 @@ private Map handle( return null; } + + @SuppressWarnings("unused") + private Map handle(ExtendedMap extendedMap) { + return null; + } + + + @SuppressWarnings("serial") + private static final class ExtendedMap extends HashMap { + } + } diff --git a/spring-web/src/test/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessorTests.java b/spring-web/src/test/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessorTests.java index 8ee2c336df47..04e6c2315562 100644 --- a/spring-web/src/test/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessorTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessorTests.java @@ -196,7 +196,7 @@ void resolveArgumentBindingDisabledPreviously() throws Exception { Object target = new TestBean(); this.container.addAttribute(name, target); - // Declare binding disabled (e.g. via @ModelAttribute method) + // Declare binding disabled (for example, via @ModelAttribute method) this.container.setBindingDisabled(name); StubRequestDataBinder dataBinder = new StubRequestDataBinder(target, name); diff --git a/spring-web/src/test/java/org/springframework/web/method/annotation/ModelFactoryOrderingTests.java b/spring-web/src/test/java/org/springframework/web/method/annotation/ModelFactoryOrderingTests.java index 59e1a2c3db36..b96eee362b7c 100644 --- a/spring-web/src/test/java/org/springframework/web/method/annotation/ModelFactoryOrderingTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/annotation/ModelFactoryOrderingTests.java @@ -67,7 +67,7 @@ class ModelFactoryOrderingTests { @BeforeEach void setup() { - this.mavContainer.addAttribute("methods", new ArrayList()); + this.mavContainer.addAttribute("methods", new ArrayList<>()); } @Test diff --git a/spring-web/src/test/java/org/springframework/web/method/annotation/RequestHeaderMethodArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/method/annotation/RequestHeaderMethodArgumentResolverTests.java index e9bc98e35065..659296a14780 100644 --- a/spring-web/src/test/java/org/springframework/web/method/annotation/RequestHeaderMethodArgumentResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/annotation/RequestHeaderMethodArgumentResolverTests.java @@ -45,7 +45,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Tests for {@link RequestHeaderMethodArgumentResolver}. @@ -69,6 +68,7 @@ class RequestHeaderMethodArgumentResolverTests { private MethodParameter paramInstant; private MethodParameter paramUuid; private MethodParameter paramUuidOptional; + private MethodParameter paramUuidPlaceholder; private MockHttpServletRequest servletRequest; @@ -93,6 +93,7 @@ void setup() throws Exception { paramInstant = new SynthesizingMethodParameter(method, 8); paramUuid = new SynthesizingMethodParameter(method, 9); paramUuidOptional = new SynthesizingMethodParameter(method, 10); + paramUuidPlaceholder = new SynthesizingMethodParameter(method, 11); servletRequest = new MockHttpServletRequest(); webRequest = new ServletWebRequest(servletRequest, new MockHttpServletResponse()); @@ -130,7 +131,6 @@ void resolveStringArrayArgument() throws Exception { servletRequest.addHeader("name", expected); Object result = resolver.resolveArgument(paramNamedValueStringArray, null, webRequest, null); - assertThat(result).isInstanceOf(String[].class); assertThat(result).isEqualTo(expected); } @@ -143,8 +143,8 @@ void resolveDefaultValue() throws Exception { @Test void resolveDefaultValueFromSystemProperty() throws Exception { - System.setProperty("systemProperty", "bar"); try { + System.setProperty("systemProperty", "bar"); Object result = resolver.resolveArgument(paramSystemProperty, null, webRequest, null); assertThat(result).isEqualTo("bar"); @@ -159,8 +159,8 @@ void resolveNameFromSystemPropertyThroughExpression() throws Exception { String expected = "foo"; servletRequest.addHeader("bar", expected); - System.setProperty("systemProperty", "bar"); try { + System.setProperty("systemProperty", "bar"); Object result = resolver.resolveArgument(paramResolvedNameWithExpression, null, webRequest, null); assertThat(result).isEqualTo(expected); @@ -175,8 +175,8 @@ void resolveNameFromSystemPropertyThroughPlaceholder() throws Exception { String expected = "foo"; servletRequest.addHeader("bar", expected); - System.setProperty("systemProperty", "bar"); try { + System.setProperty("systemProperty", "bar"); Object result = resolver.resolveArgument(paramResolvedNameWithPlaceholder, null, webRequest, null); assertThat(result).isEqualTo(expected); @@ -186,6 +186,21 @@ void resolveNameFromSystemPropertyThroughPlaceholder() throws Exception { } } + @Test + void missingParameterFromSystemPropertyThroughPlaceholder() { + try { + String expected = "bar"; + System.setProperty("systemProperty", expected); + + assertThatExceptionOfType(MissingRequestHeaderException.class) + .isThrownBy(() -> resolver.resolveArgument(paramResolvedNameWithPlaceholder, null, webRequest, null)) + .extracting("headerName").isEqualTo(expected); + } + finally { + System.clearProperty("systemProperty"); + } + } + @Test void resolveDefaultValueFromRequest() throws Exception { servletRequest.setContextPath("/bar"); @@ -247,10 +262,10 @@ void uuidConversionWithInvalidValue() { ConfigurableWebBindingInitializer bindingInitializer = new ConfigurableWebBindingInitializer(); bindingInitializer.setConversionService(new DefaultFormattingConversionService()); + DefaultDataBinderFactory binderFactory = new DefaultDataBinderFactory(bindingInitializer); - assertThatThrownBy(() -> - resolver.resolveArgument(paramUuid, null, webRequest, new DefaultDataBinderFactory(bindingInitializer))) - .isInstanceOf(MethodArgumentTypeMismatchException.class) + assertThatExceptionOfType(MethodArgumentTypeMismatchException.class) + .isThrownBy(() -> resolver.resolveArgument(paramUuid, null, webRequest, binderFactory)) .extracting("propertyName").isEqualTo("name"); } @@ -269,10 +284,10 @@ private void uuidConversionWithEmptyOrBlankValue(String uuid) { ConfigurableWebBindingInitializer bindingInitializer = new ConfigurableWebBindingInitializer(); bindingInitializer.setConversionService(new DefaultFormattingConversionService()); + DefaultDataBinderFactory binderFactory = new DefaultDataBinderFactory(bindingInitializer); - assertThatExceptionOfType(MissingRequestHeaderException.class).isThrownBy(() -> - resolver.resolveArgument(paramUuid, null, webRequest, - new DefaultDataBinderFactory(bindingInitializer))); + assertThatExceptionOfType(MissingRequestHeaderException.class) + .isThrownBy(() -> resolver.resolveArgument(paramUuid, null, webRequest, binderFactory)); } @Test @@ -296,6 +311,26 @@ private void uuidConversionWithEmptyOrBlankValueOptional(String uuid) throws Exc assertThat(result).isNull(); } + @Test + public void uuidPlaceholderConversionWithEmptyValue() { + try { + String expected = "name"; + servletRequest.addHeader(expected, ""); + + System.setProperty("systemProperty", expected); + + ConfigurableWebBindingInitializer bindingInitializer = new ConfigurableWebBindingInitializer(); + bindingInitializer.setConversionService(new DefaultFormattingConversionService()); + DefaultDataBinderFactory binderFactory = new DefaultDataBinderFactory(bindingInitializer); + + assertThatExceptionOfType(MissingRequestHeaderException.class) + .isThrownBy(() -> resolver.resolveArgument(paramUuidPlaceholder, null, webRequest, binderFactory)) + .extracting("headerName").isEqualTo(expected); + } + finally { + System.clearProperty("systemProperty"); + } + } void params( @RequestHeader(name = "name", defaultValue = "bar") String param1, @@ -308,7 +343,8 @@ void params( @RequestHeader("name") Date dateParam, @RequestHeader("name") Instant instantParam, @RequestHeader("name") UUID uuid, - @RequestHeader(name = "name", required = false) UUID uuidOptional) { + @RequestHeader(name = "name", required = false) UUID uuidOptional, + @RequestHeader(name = "${systemProperty}") UUID uuidPlaceholder) { } } diff --git a/spring-web/src/test/java/org/springframework/web/method/annotation/RequestParamMethodArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/method/annotation/RequestParamMethodArgumentResolverTests.java index 3b61880c631e..b1d4cedca676 100644 --- a/spring-web/src/test/java/org/springframework/web/method/annotation/RequestParamMethodArgumentResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/annotation/RequestParamMethodArgumentResolverTests.java @@ -22,7 +22,7 @@ import java.util.Optional; import jakarta.servlet.http.Part; -import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.propertyeditors.StringTrimmerEditor; @@ -37,7 +37,9 @@ import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.bind.support.WebRequestDataBinder; import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.support.GenericWebApplicationContext; import org.springframework.web.multipart.MultipartException; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.support.MissingServletRequestPartException; @@ -50,6 +52,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.LIST; +import static org.assertj.core.api.InstanceOfAssertFactories.array; +import static org.assertj.core.api.InstanceOfAssertFactories.optional; +import static org.assertj.core.api.InstanceOfAssertFactories.type; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.springframework.web.testfixture.method.MvcAnnotationPredicates.requestParam; @@ -64,7 +71,7 @@ */ class RequestParamMethodArgumentResolverTests { - private RequestParamMethodArgumentResolver resolver = new RequestParamMethodArgumentResolver(null, true); + private RequestParamMethodArgumentResolver resolver; private final MockHttpServletRequest request = new MockHttpServletRequest(); @@ -72,6 +79,16 @@ class RequestParamMethodArgumentResolverTests { private final ResolvableMethod testMethod = ResolvableMethod.on(getClass()).named("handle").build(); + @BeforeEach + void setup() { + GenericWebApplicationContext context = new GenericWebApplicationContext(); + context.refresh(); + resolver = new RequestParamMethodArgumentResolver(context.getBeanFactory(), true); + + // Expose request to the current thread (for SpEL expressions) + RequestContextHolder.setRequestAttributes(webRequest); + context.close(); + } @Test void supportsParameter() { @@ -141,6 +158,12 @@ void supportsParameter() { param = this.testMethod.annotPresent(RequestPart.class).arg(MultipartFile.class); assertThat(resolver.supportsParameter(param)).isFalse(); + + param = this.testMethod.annotPresent(RequestParam.class).arg(Integer.class); + assertThat(resolver.supportsParameter(param)).isTrue(); + + param = this.testMethod.annotPresent(RequestParam.class).arg(int.class); + assertThat(resolver.supportsParameter(param)).isTrue(); } @Test @@ -150,7 +173,7 @@ void resolveString() throws Exception { MethodParameter param = this.testMethod.annot(requestParam().notRequired("bar")).arg(String.class); Object result = resolver.resolveArgument(param, null, webRequest, null); - assertThat(result).as("Invalid result").isEqualTo(expected); + assertThat(result).isEqualTo(expected); } @Test @@ -160,7 +183,7 @@ void resolveStringArray() throws Exception { MethodParameter param = this.testMethod.annotPresent(RequestParam.class).arg(String[].class); Object result = resolver.resolveArgument(param, null, webRequest, null); - assertThat(result).as("Invalid result").isEqualTo(expected); + assertThat(result).asInstanceOf(array(String[].class)).containsExactly(expected); } @Test // gh-32577 @@ -171,7 +194,7 @@ void resolveStringArrayWithEmptyArraySuffix() throws Exception { MethodParameter param = this.testMethod.annotPresent(RequestParam.class).arg(String[].class); Object result = resolver.resolveArgument(param, null, webRequest, null); - assertThat(result).isEqualTo(expected); + assertThat(result).asInstanceOf(array(String[].class)).containsExactly(expected); } @Test @@ -183,7 +206,7 @@ void resolveMultipartFile() throws Exception { MethodParameter param = this.testMethod.annotPresent(RequestParam.class).arg(MultipartFile.class); Object result = resolver.resolveArgument(param, null, webRequest, null); - assertThat(result).as("Invalid result").isEqualTo(expected); + assertThat(result).asInstanceOf(type(MultipartFile.class)).isEqualTo(expected); } @Test @@ -198,7 +221,7 @@ void resolveMultipartFileList() throws Exception { MethodParameter param = this.testMethod.annotPresent(RequestParam.class).arg(List.class, MultipartFile.class); Object result = resolver.resolveArgument(param, null, webRequest, null); - assertThat(result).isEqualTo(Arrays.asList(expected1, expected2)); + assertThat(result).asInstanceOf(LIST).containsExactly(expected1, expected2); } @Test @@ -224,11 +247,7 @@ void resolveMultipartFileArray() throws Exception { MethodParameter param = this.testMethod.annotPresent(RequestParam.class).arg(MultipartFile[].class); Object result = resolver.resolveArgument(param, null, webRequest, null); - assertThat(result instanceof MultipartFile[]).isTrue(); - MultipartFile[] parts = (MultipartFile[]) result; - assertThat(parts).hasSize(2); - assertThat(expected1).isEqualTo(parts[0]); - assertThat(expected2).isEqualTo(parts[1]); + assertThat(result).asInstanceOf(array(MultipartFile[].class)).containsExactly(expected1, expected2); } @Test @@ -253,7 +272,7 @@ void resolvePart() throws Exception { MethodParameter param = this.testMethod.annotPresent(RequestParam.class).arg(Part.class); Object result = resolver.resolveArgument(param, null, webRequest, null); - assertThat(result).as("Invalid result").isEqualTo(expected); + assertThat(result).asInstanceOf(type(Part.class)).isEqualTo(expected); } @Test @@ -270,7 +289,7 @@ void resolvePartList() throws Exception { MethodParameter param = this.testMethod.annotPresent(RequestParam.class).arg(List.class, Part.class); Object result = resolver.resolveArgument(param, null, webRequest, null); - assertThat(result).isEqualTo(Arrays.asList(expected1, expected2)); + assertThat(result).asInstanceOf(LIST).containsExactly(expected1, expected2); } @Test @@ -300,11 +319,7 @@ void resolvePartArray() throws Exception { MethodParameter param = this.testMethod.annotPresent(RequestParam.class).arg(Part[].class); Object result = resolver.resolveArgument(param, null, webRequest, null); - assertThat(result instanceof Part[]).isTrue(); - Part[] parts = (Part[]) result; - assertThat(parts).hasSize(2); - assertThat(expected1).isEqualTo(parts[0]); - assertThat(expected2).isEqualTo(parts[1]); + assertThat(result).asInstanceOf(array(Part[].class)).containsExactly(expected1, expected2); } @Test @@ -329,7 +344,7 @@ void resolveMultipartFileNotAnnot() throws Exception { MethodParameter param = this.testMethod.annotNotPresent().arg(MultipartFile.class); Object result = resolver.resolveArgument(param, null, webRequest, null); - assertThat(result).as("Invalid result").isEqualTo(expected); + assertThat(result).asInstanceOf(type(MultipartFile.class)).isEqualTo(expected); } @Test @@ -345,7 +360,7 @@ void resolveMultipartFileListNotannot() throws Exception { .annotNotPresent(RequestParam.class).arg(List.class, MultipartFile.class); Object result = resolver.resolveArgument(param, null, webRequest, null); - assertThat(result).isEqualTo(Arrays.asList(expected1, expected2)); + assertThat(result).asInstanceOf(LIST).containsExactly(expected1, expected2); } @Test @@ -367,8 +382,7 @@ public void isMultipartRequestHttpPut() throws Exception { .annotNotPresent(RequestParam.class).arg(List.class, MultipartFile.class); Object actual = resolver.resolveArgument(param, null, webRequest, null); - assertThat(actual).isInstanceOf(List.class).asInstanceOf(InstanceOfAssertFactories.LIST) - .containsExactly(expected); + assertThat(actual).asInstanceOf(LIST).containsExactly(expected); } @Test @@ -399,14 +413,14 @@ void resolvePartNotAnnot() throws Exception { MethodParameter param = this.testMethod.annotNotPresent(RequestParam.class).arg(Part.class); Object result = resolver.resolveArgument(param, null, webRequest, null); - assertThat(result).as("Invalid result").isEqualTo(expected); + assertThat(result).asInstanceOf(type(Part.class)).isEqualTo(expected); } @Test void resolveDefaultValue() throws Exception { MethodParameter param = this.testMethod.annot(requestParam().notRequired("bar")).arg(String.class); Object result = resolver.resolveArgument(param, null, webRequest, null); - assertThat(result).as("Invalid result").isEqualTo("bar"); + assertThat(result).isEqualTo("bar"); } @Test @@ -622,8 +636,7 @@ void resolveOptionalMultipartFile() throws Exception { MethodParameter param = this.testMethod.annotPresent(RequestParam.class).arg(Optional.class, MultipartFile.class); Object result = resolver.resolveArgument(param, null, webRequest, binderFactory); - assertThat(result instanceof Optional).isTrue(); - assertThat(((Optional) result).get()).as("Invalid result").isEqualTo(expected); + assertThat(result).asInstanceOf(optional(MultipartFile.class)).contains(expected); } @Test @@ -653,6 +666,54 @@ void optionalMultipartFileWithoutMultipartRequest() throws Exception { assertThat(actual).isEqualTo(Optional.empty()); } + @Test + void resolveNameFromSystemPropertyThroughPlaceholder() throws Exception { + ConfigurableWebBindingInitializer initializer = new ConfigurableWebBindingInitializer(); + initializer.setConversionService(new DefaultConversionService()); + WebDataBinderFactory binderFactory = new DefaultDataBinderFactory(initializer); + + Integer expected = 100; + request.addParameter("name", expected.toString()); + + System.setProperty("systemProperty", "name"); + + try { + MethodParameter param = this.testMethod.annot(requestParam().name("${systemProperty}")).arg(Integer.class); + Object result = resolver.resolveArgument(param, null, webRequest, binderFactory); + assertThat(result).isInstanceOf(Integer.class); + } + finally { + System.clearProperty("systemProperty"); + } + } + + @Test + void missingParameterFromSystemPropertyThroughPlaceholder() { + String expected = "name"; + System.setProperty("systemProperty", expected); + + MethodParameter param = this.testMethod.annot(requestParam().name("${systemProperty}")).arg(Integer.class); + assertThatThrownBy(() -> + resolver.resolveArgument(param, null, webRequest, null)) + .isInstanceOf(MissingServletRequestParameterException.class) + .extracting("parameterName").isEqualTo(expected); + + System.clearProperty("systemProperty"); + } + + @Test + void notNullablePrimitiveParameterFromSystemPropertyThroughPlaceholder() { + String expected = "sysbar"; + System.setProperty("systemProperty", expected); + + MethodParameter param = this.testMethod.annot(requestParam().name("${systemProperty}").notRequired()).arg(int.class); + assertThatThrownBy(() -> + resolver.resolveArgument(param, null, webRequest, null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(expected); + + System.clearProperty("systemProperty"); + } @SuppressWarnings({"unused", "OptionalUsedAsFieldOrParameterType"}) public void handle( @@ -677,7 +738,9 @@ public void handle( @RequestParam("name") Optional paramOptionalArray, @RequestParam("name") Optional> paramOptionalList, @RequestParam("mfile") Optional multipartFileOptional, - @RequestParam(defaultValue = "false") Boolean booleanParam) { + @RequestParam(defaultValue = "false") Boolean booleanParam, + @RequestParam("${systemProperty}") Integer placeholderParam, + @RequestParam(name = "${systemProperty}", required = false) int primitivePlaceholderParam) { } } diff --git a/spring-web/src/test/java/org/springframework/web/method/support/HandlerMethodValidationExceptionTests.java b/spring-web/src/test/java/org/springframework/web/method/support/HandlerMethodValidationExceptionTests.java index c7aceb0c0b88..c14791d7721b 100644 --- a/spring-web/src/test/java/org/springframework/web/method/support/HandlerMethodValidationExceptionTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/support/HandlerMethodValidationExceptionTests.java @@ -128,7 +128,8 @@ private static MethodValidationResult createMethodValidationResult(HandlerMethod } else { MessageSourceResolvable error = new DefaultMessageSourceResolvable("Size"); - return new ParameterValidationResult(param, "123", List.of(error), null, null, null); + return new ParameterValidationResult( + param, "123", List.of(error), null, null, null, (e, t) -> null); } }) .toList()); diff --git a/spring-web/src/test/java/org/springframework/web/server/adapter/HttpWebHandlerAdapterObservabilityTests.java b/spring-web/src/test/java/org/springframework/web/server/adapter/HttpWebHandlerAdapterObservabilityTests.java index 8f83da58f54b..20fdb4328ceb 100644 --- a/spring-web/src/test/java/org/springframework/web/server/adapter/HttpWebHandlerAdapterObservabilityTests.java +++ b/spring-web/src/test/java/org/springframework/web/server/adapter/HttpWebHandlerAdapterObservabilityTests.java @@ -95,8 +95,7 @@ private HttpWebHandlerAdapter createWebHandler(WebHandler targetHandler) { } private TestObservationRegistryAssert.TestObservationRegistryAssertReturningObservationContextAssert assertThatHttpObservation() { - return TestObservationRegistryAssert.assertThat(this.observationRegistry) - .hasObservationWithNameEqualTo("http.server.requests").that(); + return assertThat(this.observationRegistry).hasObservationWithNameEqualTo("http.server.requests").that(); } diff --git a/spring-web/src/test/java/org/springframework/web/server/adapter/WebHttpHandlerBuilderTests.java b/spring-web/src/test/java/org/springframework/web/server/adapter/WebHttpHandlerBuilderTests.java index 4d9283b7294b..57e43a558faa 100644 --- a/spring-web/src/test/java/org/springframework/web/server/adapter/WebHttpHandlerBuilderTests.java +++ b/spring-web/src/test/java/org/springframework/web/server/adapter/WebHttpHandlerBuilderTests.java @@ -23,7 +23,6 @@ import java.util.function.Function; import io.micrometer.observation.tck.TestObservationRegistry; -import io.micrometer.observation.tck.TestObservationRegistryAssert; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -171,8 +170,8 @@ void observationRegistry() { handler.handle(MockServerHttpRequest.get("/").build(), response).block(); TestObservationRegistry observationRegistry = applicationContext.getBean(TestObservationRegistry.class); - TestObservationRegistryAssert.assertThat(observationRegistry).hasObservationWithNameEqualTo("http.server.requests") - .that().hasLowCardinalityKeyValue("uri", "UNKNOWN"); + assertThat(observationRegistry).hasObservationWithNameEqualTo("http.server.requests").that() + .hasLowCardinalityKeyValue("uri", "UNKNOWN"); } @Test diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpMethodArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpMethodArgumentResolverTests.java index d060b550af7b..e2f87f01cca4 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpMethodArgumentResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpMethodArgumentResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.web.service.invoker; +import java.util.Optional; + import org.junit.jupiter.api.Test; import org.springframework.http.HttpMethod; @@ -68,12 +70,38 @@ void nullHttpMethod() { assertThatIllegalArgumentException().isThrownBy(() -> this.service.execute(null)); } + @Test + void nullHttpMethodWithNullable() { + this.service.executeNullableHttpMethod(null); + assertThat(getActualMethod()).isEqualTo(HttpMethod.GET); + } + + @Test + void nullHttpMethodWithOptional() { + this.service.executeOptionalHttpMethod(null); + assertThat(getActualMethod()).isEqualTo(HttpMethod.GET); + } + + @Test + void emptyOptionalHttpMethod() { + this.service.executeOptionalHttpMethod(Optional.empty()); + assertThat(getActualMethod()).isEqualTo(HttpMethod.GET); + } + + @Test + void optionalHttpMethod() { + this.service.executeOptionalHttpMethod(Optional.of(HttpMethod.POST)); + assertThat(getActualMethod()).isEqualTo(HttpMethod.POST); + } + + @Nullable private HttpMethod getActualMethod() { return this.client.getRequestValues().getHttpMethod(); } + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") private interface Service { @HttpExchange @@ -85,6 +113,12 @@ private interface Service { @GetExchange void executeNotHttpMethod(String test); + @GetExchange + void executeNullableHttpMethod(@Nullable HttpMethod method); + + @GetExchange + void executeOptionalHttpMethod(Optional method); + } } diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpRequestValuesTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpRequestValuesTests.java index 481da00d5f7e..5f026afe6545 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpRequestValuesTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpRequestValuesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,17 +79,17 @@ void queryParamsWithUriTemplate() { assertThat(uriTemplate) .isEqualTo("/path?" + - "{queryParam0}={queryParam0[0]}&" + - "{queryParam1}={queryParam1[0]}&" + - "{queryParam1}={queryParam1[1]}"); + "{param1}={param1[0]}&" + + "{param2}={param2[0]}&" + + "{param2}={param2[1]}"); assertThat(requestValues.getUriVariables()) - .containsOnlyKeys("queryParam0", "queryParam1", "queryParam0[0]", "queryParam1[0]", "queryParam1[1]") - .containsEntry("queryParam0", "param1") - .containsEntry("queryParam1", "param2") - .containsEntry("queryParam0[0]", "1st value") - .containsEntry("queryParam1[0]", "2nd value A") - .containsEntry("queryParam1[1]", "2nd value B"); + .containsOnlyKeys("param1", "param2", "param1[0]", "param2[0]", "param2[1]") + .containsEntry("param1", "param1") + .containsEntry("param2", "param2") + .containsEntry("param1[0]", "1st value") + .containsEntry("param2[0]", "2nd value A") + .containsEntry("param2[1]", "2nd value B"); URI uri = UriComponentsBuilder.fromUriString(uriTemplate) .encode() @@ -99,6 +99,24 @@ void queryParamsWithUriTemplate() { .isEqualTo("/path?param1=1st%20value¶m2=2nd%20value%20A¶m2=2nd%20value%20B"); } + @Test // gh-34364 + void queryParamWithSemicolon() { + HttpRequestValues requestValues = HttpRequestValues.builder().setHttpMethod(HttpMethod.POST) + .setUriTemplate("/path") + .addRequestParameter("userId:eq", "test value") + .build(); + + String uriTemplate = requestValues.getUriTemplate(); + assertThat(uriTemplate).isEqualTo("/path?{userId%3Aeq}={userId%3Aeq[0]}"); + + URI uri = UriComponentsBuilder.fromUriString(uriTemplate) + .encode() + .build(requestValues.getUriVariables()); + + assertThat(uri.toString()) + .isEqualTo("/path?userId%3Aeq=test%20value"); + } + @Test void queryParamsWithPreparedUri() { @@ -144,7 +162,13 @@ void requestPartAndRequestParam() { String uriTemplate = requestValues.getUriTemplate(); assertThat(uriTemplate).isNotNull(); - assertThat(uriTemplate).isEqualTo("/path?{queryParam0}={queryParam0[0]}"); + assertThat(uriTemplate).isEqualTo("/path?{query param}={query param[0]}"); + + URI uri = UriComponentsBuilder.fromUriString(uriTemplate) + .encode() + .build(requestValues.getUriVariables()); + assertThat(uri.toString()) + .isEqualTo("/path?query%20param=query%20value"); @SuppressWarnings("unchecked") MultiValueMap map = (MultiValueMap) requestValues.getBodyValue(); diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java index b890ec83eafc..012d70f74c22 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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,6 +48,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.springframework.http.MediaType.APPLICATION_CBOR_VALUE; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.APPLICATION_NDJSON_VALUE; /** * Tests for {@link HttpServiceMethod} with @@ -184,6 +185,15 @@ void methodAnnotatedService() { assertThat(requestValues.getUriTemplate()).isEqualTo("/url"); assertThat(requestValues.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(requestValues.getHeaders().getAccept()).containsOnly(MediaType.APPLICATION_JSON); + + service.performGetWithHeaders(); + + requestValues = this.client.getRequestValues(); + assertThat(requestValues.getHttpMethod()).isEqualTo(HttpMethod.GET); + assertThat(requestValues.getUriTemplate()).isEmpty(); + assertThat(requestValues.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + assertThat(requestValues.getHeaders().getAccept()).isEmpty(); + assertThat(requestValues.getHeaders().get("CustomHeader")).containsExactly("a", "b", "c"); } @Test @@ -338,6 +348,12 @@ private interface MethodLevelAnnotatedService { @PostExchange(url = "/url", contentType = APPLICATION_JSON_VALUE, accept = APPLICATION_JSON_VALUE) void performPost(); + @HttpExchange( + method = "GET", + contentType = APPLICATION_JSON_VALUE, + headers = {"CustomHeader=a,b, c", "Content-Type=" + APPLICATION_NDJSON_VALUE}) + void performGetWithHeaders(); + } diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/NamedValueArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/NamedValueArgumentResolverTests.java index 399b5c3f4f5d..4e677140b52f 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/NamedValueArgumentResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/NamedValueArgumentResolverTests.java @@ -48,6 +48,7 @@ * {@link TestValue @TestValue} annotation and {@link TestNamedValueArgumentResolver}. * * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma */ class NamedValueArgumentResolverTests { @@ -73,6 +74,12 @@ void dateTestValue() { assertTestValue("value", "2022-09-16"); } + @Test // gh-33794 + void dateNullValue() { + this.service.executeDate(null); + assertTestValue("value"); + } + @Test void objectTestValue() { this.service.execute(Boolean.TRUE); @@ -134,7 +141,7 @@ void optionalEmpty() { } @Test - void optionalEmpthyWithDefaultValue() { + void optionalEmptyWithDefaultValue() { this.service.executeOptionalWithDefaultValue(Optional.empty()); assertTestValue("value", "default"); } @@ -157,6 +164,12 @@ void mapOfTestValuesHasOptionalValue() { assertTestValue("value", "test"); } + @Test + void nullTestValueWithNullable() { + this.service.executeNullable(null); + assertTestValue("value"); + } + private void assertTestValue(String key, String... values) { List actualValues = this.argumentResolver.getTestValues().get(key); if (ObjectUtils.isEmpty(values)) { @@ -175,7 +188,7 @@ private interface Service { void executeString(@TestValue String value); @GetExchange - void executeDate(@TestValue @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate value); + void executeDate(@Nullable @TestValue(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate value); @GetExchange void execute(@TestValue Object value); @@ -207,6 +220,9 @@ private interface Service { @GetExchange void executeMapWithOptionalValue(@TestValue Map> values); + @GetExchange + void executeNullable(@Nullable @TestValue String value); + } diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestBodyArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestBodyArgumentResolverTests.java index 12b8b81575d9..3b82d00164a1 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestBodyArgumentResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestBodyArgumentResolverTests.java @@ -16,6 +16,8 @@ package org.springframework.web.service.invoker; +import java.util.Optional; + import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Single; import org.junit.jupiter.api.Test; @@ -35,7 +37,9 @@ * Tests for {@link RequestBodyArgumentResolver}. * * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma */ +@SuppressWarnings({"DataFlowIssue", "OptionalAssignedToNull"}) class RequestBodyArgumentResolverTests { private final TestReactorExchangeAdapter client = new TestReactorExchangeAdapter(); @@ -102,18 +106,68 @@ void notRequestBody() { } @Test - void ignoreNull() { - this.service.execute(null); + void nullRequestBody() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.service.execute(null)) + .withMessage("RequestBody is required"); + + assertThatIllegalArgumentException() + .isThrownBy(() -> this.service.executeMono(null)) + .withMessage("RequestBody is required"); + } + + @Test + void nullRequestBodyWithNullable() { + this.service.executeNullable(null); + + assertThat(getBodyValue()).isNull(); + assertThat(getPublisherBody()).isNull(); + + this.service.executeNullableMono(null); + + assertThat(getBodyValue()).isNull(); + assertThat(getPublisherBody()).isNull(); + } + + @Test + void nullRequestBodyWithNotRequired() { + this.service.executeNotRequired(null); + + assertThat(getBodyValue()).isNull(); + assertThat(getPublisherBody()).isNull(); + + this.service.executeNotRequiredMono(null); assertThat(getBodyValue()).isNull(); assertThat(getPublisherBody()).isNull(); + } - this.service.executeMono(null); + @Test + void nullRequestBodyWithOptional() { + this.service.executeOptional(null); assertThat(getBodyValue()).isNull(); assertThat(getPublisherBody()).isNull(); } + @Test + void emptyOptionalRequestBody() { + this.service.executeOptional(Optional.empty()); + + assertThat(getBodyValue()).isNull(); + assertThat(getPublisherBody()).isNull(); + } + + @Test + void optionalStringBody() { + String body = "bodyValue"; + this.service.executeOptional(Optional.of(body)); + + assertThat(getBodyValue()).isEqualTo(body); + assertThat(getPublisherBody()).isNull(); + } + + @Nullable private Object getBodyValue() { return getReactiveRequestValues().getBodyValue(); @@ -134,14 +188,30 @@ private ReactiveHttpRequestValues getReactiveRequestValues() { } + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") private interface Service { @GetExchange void execute(@RequestBody String body); + @GetExchange + void executeNullable(@Nullable @RequestBody String body); + + @GetExchange + void executeNotRequired(@RequestBody(required = false) String body); + + @GetExchange + void executeOptional(@RequestBody Optional body); + @GetExchange void executeMono(@RequestBody Mono body); + @GetExchange + void executeNullableMono(@Nullable @RequestBody Mono body); + + @GetExchange + void executeNotRequiredMono(@RequestBody(required = false) Mono body); + @GetExchange void executeSingle(@RequestBody Single body); diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestHeaderArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestHeaderArgumentResolverTests.java index 537c2a816c1d..4c19292e59f2 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestHeaderArgumentResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestHeaderArgumentResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author 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.util.ObjectUtils; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; import static org.assertj.core.api.Assertions.assertThat; @@ -32,6 +33,7 @@ * * @author Olga Maciaszek-Sharma * @author Rossen Stoyanchev + * @author Yanming Zhou */ class RequestHeaderArgumentResolverTests { @@ -49,6 +51,12 @@ void header() { assertRequestHeaders("id", "test"); } + @Test + void doesNotOverrideAnnotationHeaders() { + this.service.executeWithAnnotationHeaders("2"); + assertRequestHeaders("myHeader", "1", "2"); + } + private void assertRequestHeaders(String key, String... values) { List actualValues = this.client.getRequestValues().getHeaders().get(key); if (ObjectUtils.isEmpty(values)) { @@ -65,6 +73,9 @@ private interface Service { @GetExchange void execute(@RequestHeader String id); + @HttpExchange(method = "GET", headers = "myHeader=1") + void executeWithAnnotationHeaders(@RequestHeader String myHeader); + } } diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestParamArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestParamArgumentResolverTests.java index f6cd274d01e9..d68d725bdb15 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/RequestParamArgumentResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/RequestParamArgumentResolverTests.java @@ -17,12 +17,17 @@ package org.springframework.web.service.invoker; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; +import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; import org.springframework.web.service.annotation.PostExchange; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; import static org.assertj.core.api.Assertions.assertThat; @@ -41,14 +46,14 @@ class RequestParamArgumentResolverTests { private final TestExchangeAdapter client = new TestExchangeAdapter(); - private final Service service = - HttpServiceProxyFactory.builderFor(this.client).build().createClient(Service.class); + private final HttpServiceProxyFactory.Builder builder = HttpServiceProxyFactory.builderFor(this.client); @Test @SuppressWarnings("unchecked") void requestParam() { - this.service.postForm("value 1", "value 2"); + Service service = builder.build().createClient(Service.class); + service.postForm("value 1", "value 2"); Object body = this.client.getRequestValues().getBodyValue(); assertThat(body).isInstanceOf(MultiValueMap.class); @@ -57,12 +62,28 @@ void requestParam() { .containsEntry("param2", List.of("value 2")); } + @Test + void requestParamWithDisabledFormattingCollectionValue() { + RequestParamArgumentResolver resolver = new RequestParamArgumentResolver(new DefaultConversionService()); + resolver.setFavorSingleValue(true); + + Service service = builder.customArgumentResolver(resolver).build().createClient(Service.class); + service.getWithParams("value 1", List.of("1", "2", "3")); + + HttpRequestValues values = this.client.getRequestValues(); + String uriTemplate = values.getUriTemplate(); + Map uriVariables = values.getUriVariables(); + UriComponents uri = UriComponentsBuilder.fromUriString(uriTemplate).buildAndExpand(uriVariables).encode(); + assertThat(uri.getQuery()).isEqualTo("param1=value%201¶m2=1,2,3"); + } private interface Service { @PostExchange(contentType = "application/x-www-form-urlencoded") void postForm(@RequestParam String param1, @RequestParam String param2); + @GetExchange + void getWithParams(@RequestParam String param1, @RequestParam List param2); } } diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolverTests.java index 670d680929aa..36b8c9ec562c 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/UriBuilderFactoryArgumentResolverTests.java @@ -16,6 +16,8 @@ package org.springframework.web.service.invoker; +import java.util.Optional; + import org.junit.jupiter.api.Test; import org.springframework.lang.Nullable; @@ -24,12 +26,14 @@ import org.springframework.web.util.UriBuilderFactory; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; /** * Tests for {@link UriBuilderFactoryArgumentResolver}. * * @author Olga Maciaszek-Sharma */ +@SuppressWarnings({"DataFlowIssue", "OptionalAssignedToNull"}) class UriBuilderFactoryArgumentResolverTests { private final TestExchangeAdapter client = new TestExchangeAdapter(); @@ -49,24 +53,65 @@ void uriBuilderFactory(){ } @Test - void ignoreNullUriBuilderFactory(){ - this.service.execute(null); + void nullUriBuilderFactory() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.service.execute(null)) + .withMessage("UriBuilderFactory is required"); + } + + @Test + void nullUriBuilderFactoryWithNullable(){ + this.service.executeNullable(null); assertThat(getRequestValues().getUriBuilderFactory()).isEqualTo(null); assertThat(getRequestValues().getUriTemplate()).isEqualTo("/path"); assertThat(getRequestValues().getUri()).isNull(); } + @Test + void nullUriBuilderFactoryWithOptional(){ + this.service.executeOptional(null); + + assertThat(getRequestValues().getUriBuilderFactory()).isEqualTo(null); + assertThat(getRequestValues().getUriTemplate()).isEqualTo("/path"); + assertThat(getRequestValues().getUri()).isNull(); + } + + @Test + void emptyOptionalUriBuilderFactory(){ + this.service.executeOptional(Optional.empty()); + + assertThat(getRequestValues().getUriBuilderFactory()).isEqualTo(null); + assertThat(getRequestValues().getUriTemplate()).isEqualTo("/path"); + assertThat(getRequestValues().getUri()).isNull(); + } + + @Test + void optionalUriBuilderFactory(){ + UriBuilderFactory factory = new DefaultUriBuilderFactory("https://example.com"); + this.service.executeOptional(Optional.of(factory)); + + assertThat(getRequestValues().getUriBuilderFactory()).isEqualTo(factory); + assertThat(getRequestValues().getUriTemplate()).isEqualTo("/path"); + assertThat(getRequestValues().getUri()).isNull(); + } private HttpRequestValues getRequestValues() { return this.client.getRequestValues(); } + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") private interface Service { @GetExchange("/path") - void execute(@Nullable UriBuilderFactory uri); + void execute(UriBuilderFactory uri); + + @GetExchange("/path") + void executeNullable(@Nullable UriBuilderFactory uri); + + @GetExchange("/path") + void executeOptional(Optional uri); } } diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/UrlArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/UrlArgumentResolverTests.java index 668373ccf133..2039e851a48c 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/UrlArgumentResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/UrlArgumentResolverTests.java @@ -17,6 +17,7 @@ package org.springframework.web.service.invoker; import java.net.URI; +import java.util.Optional; import org.junit.jupiter.api.Test; @@ -24,13 +25,16 @@ import org.springframework.web.service.annotation.GetExchange; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** * Tests for {@link UrlArgumentResolver}. * * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma */ +@SuppressWarnings({"DataFlowIssue", "OptionalAssignedToNull"}) class UrlArgumentResolverTests { private final TestExchangeAdapter client = new TestExchangeAdapter(); @@ -59,22 +63,62 @@ void notUrl() { } @Test - void ignoreNull() { - this.service.execute(null); + void nullUrl() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.service.execute(null)) + .withMessage("URI is required"); + } + + @Test + void nullUrlWithNullable() { + this.service.executeNullable(null); + + assertThat(getRequestValues().getUri()).isNull(); + assertThat(getRequestValues().getUriTemplate()).isEqualTo("/path"); + } + + @Test + void nullUrlWithOptional() { + this.service.executeOptional(null); assertThat(getRequestValues().getUri()).isNull(); assertThat(getRequestValues().getUriTemplate()).isEqualTo("/path"); } + @Test + void emptyOptionalUrl() { + this.service.executeOptional(Optional.empty()); + + assertThat(getRequestValues().getUri()).isNull(); + assertThat(getRequestValues().getUriTemplate()).isEqualTo("/path"); + } + + @Test + void optionalUrl() { + URI dynamicUrl = URI.create("dynamic-path"); + this.service.executeOptional(Optional.of(dynamicUrl)); + + assertThat(getRequestValues().getUri()).isEqualTo(dynamicUrl); + assertThat(getRequestValues().getUriTemplate()).isEqualTo("/path"); + } + + private HttpRequestValues getRequestValues() { return this.client.getRequestValues(); } + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") private interface Service { @GetExchange("/path") - void execute(@Nullable URI uri); + void execute(URI uri); + + @GetExchange("/path") + void executeNullable(@Nullable URI uri); + + @GetExchange("/path") + void executeOptional(Optional uri); @GetExchange void executeNotUri(String other); diff --git a/spring-web/src/test/java/org/springframework/web/util/ContentCachingRequestWrapperTests.java b/spring-web/src/test/java/org/springframework/web/util/ContentCachingRequestWrapperTests.java index fe710e1695b1..d955c70010e5 100644 --- a/spring-web/src/test/java/org/springframework/web/util/ContentCachingRequestWrapperTests.java +++ b/spring-web/src/test/java/org/springframework/web/util/ContentCachingRequestWrapperTests.java @@ -17,15 +17,12 @@ package org.springframework.web.util; import java.io.UnsupportedEncodingException; -import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.Test; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; -import org.springframework.util.FastByteArrayOutputStream; -import org.springframework.util.ReflectionUtils; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; import static org.assertj.core.api.Assertions.assertThat; @@ -95,13 +92,7 @@ void cachedContentToStringWithLimit() throws Exception { @Test void shouldNotAllocateMoreThanCacheLimit() throws Exception { ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(createGetRequest("Hello World"), CONTENT_CACHE_LIMIT); - Field field = ReflectionUtils.findField(ContentCachingRequestWrapper.class, "cachedContent"); - ReflectionUtils.makeAccessible(field); - FastByteArrayOutputStream cachedContent = (FastByteArrayOutputStream) ReflectionUtils.getField(field, wrapper); - field = ReflectionUtils.findField(FastByteArrayOutputStream.class, "initialBlockSize"); - ReflectionUtils.makeAccessible(field); - int blockSize = (int) ReflectionUtils.getField(field, cachedContent); - assertThat(blockSize).isEqualTo(CONTENT_CACHE_LIMIT); + assertThat(wrapper).extracting("cachedContent.initialBlockSize").isEqualTo(CONTENT_CACHE_LIMIT); } @@ -115,8 +106,8 @@ protected void handleContentOverflow(int contentCacheLimit) { } }; - assertThatIllegalStateException().isThrownBy(() -> - wrapper.getInputStream().readAllBytes()) + assertThatIllegalStateException() + .isThrownBy(() -> wrapper.getInputStream().readAllBytes()) .withMessage("3"); } diff --git a/spring-web/src/test/java/org/springframework/web/util/DisconnectedClientHelperTests.java b/spring-web/src/test/java/org/springframework/web/util/DisconnectedClientHelperTests.java new file mode 100644 index 000000000000..296a19209272 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/util/DisconnectedClientHelperTests.java @@ -0,0 +1,93 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.util; + +import java.io.EOFException; +import java.io.IOException; +import java.util.List; + +import org.apache.catalina.connector.ClientAbortException; +import org.eclipse.jetty.io.EofException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.netty.channel.AbortedException; + +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.context.request.async.AsyncRequestNotUsableException; +import org.springframework.web.testfixture.http.MockHttpInputMessage; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link DisconnectedClientHelper}. + * @author Rossen Stoyanchev + */ +public class DisconnectedClientHelperTests { + + @ParameterizedTest + @ValueSource(strings = {"broKen pipe", "connection reset By peer"}) + void exceptionPhrases(String phrase) { + Exception ex = new IOException(phrase); + assertThat(DisconnectedClientHelper.isClientDisconnectedException(ex)).isTrue(); + + ex = new IOException(ex); + assertThat(DisconnectedClientHelper.isClientDisconnectedException(ex)).isTrue(); + } + + @Test + void connectionResetExcluded() { + Exception ex = new IOException("connection reset"); + assertThat(DisconnectedClientHelper.isClientDisconnectedException(ex)).isFalse(); + } + + @ParameterizedTest + @MethodSource("disconnectedExceptions") + void name(Exception ex) { + assertThat(DisconnectedClientHelper.isClientDisconnectedException(ex)).isTrue(); + } + + static List disconnectedExceptions() { + return List.of( + new AbortedException(""), new ClientAbortException(""), + new EOFException(), new EofException(), new AsyncRequestNotUsableException("")); + } + + @Test // gh-33064 + void nestedDisconnectedException() { + Exception ex = new HttpMessageNotReadableException( + "I/O error while reading input message", new ClientAbortException(), + new MockHttpInputMessage(new byte[0])); + + assertThat(DisconnectedClientHelper.isClientDisconnectedException(ex)).isTrue(); + } + + @Test // gh-34264 + void onwardClientDisconnectedExceptionPhrase() { + Exception ex = new ResourceAccessException("I/O error", new EOFException("Connection reset by peer")); + assertThat(DisconnectedClientHelper.isClientDisconnectedException(ex)).isFalse(); + } + + @Test + void onwardClientDisconnectedExceptionType() { + Exception ex = new ResourceAccessException("I/O error", new EOFException()); + assertThat(DisconnectedClientHelper.isClientDisconnectedException(ex)).isFalse(); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/util/ForwardedHeaderUtilsTests.java b/spring-web/src/test/java/org/springframework/web/util/ForwardedHeaderUtilsTests.java index 01199a582ecd..f5921e7ff2bf 100644 --- a/spring-web/src/test/java/org/springframework/web/util/ForwardedHeaderUtilsTests.java +++ b/spring-web/src/test/java/org/springframework/web/util/ForwardedHeaderUtilsTests.java @@ -17,6 +17,8 @@ package org.springframework.web.util; import java.net.URI; +import java.util.Collections; +import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -363,6 +365,11 @@ public URI getURI() { return UriComponentsBuilder.fromUriString("/").build().toUri(); } + @Override + public Map getAttributes() { + return Collections.emptyMap(); + } + @Override public HttpHeaders getHeaders() { return new HttpHeaders(); 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 67744a2b3363..ce22e8e4c885 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 @@ -27,13 +27,15 @@ import java.util.function.BiConsumer; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriComponentsBuilder.ParserType; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** * Tests for {@link UriComponentsBuilder}. @@ -45,11 +47,13 @@ * @author Juergen Hoeller * @author Sam Brannen * @author David Eckel + * @author Yanming Zhou */ class UriComponentsBuilderTests { - @Test // see gh-26453 - void examplesInReferenceManual() { + @ParameterizedTest // see gh-26453 + @EnumSource + void examplesInReferenceManual(ParserType parserType) { final String expected = "/hotel%20list/New%20York?q=foo%2Bbar"; URI uri = UriComponentsBuilder.fromPath("/hotel list/{city}") @@ -64,7 +68,7 @@ void examplesInReferenceManual() { .build("New York", "foo+bar"); assertThat(uri).asString().isEqualTo(expected); - uri = UriComponentsBuilder.fromUriString("/hotel list/{city}?q={q}") + uri = UriComponentsBuilder.fromUriString("/hotel list/{city}?q={q}", parserType) .build("New York", "foo+bar"); assertThat(uri).asString().isEqualTo(expected); } @@ -151,19 +155,21 @@ void fromOpaqueUri() { assertThat(result.toUri()).as("Invalid result URI").isEqualTo(uri); } - @Test // SPR-9317 - void fromUriEncodedQuery() { + @ParameterizedTest // see gh-9317 + @EnumSource + void fromUriEncodedQuery(ParserType parserType) { URI uri = URI.create("https://www.example.org/?param=aGVsbG9Xb3JsZA%3D%3D"); String fromUri = UriComponentsBuilder.fromUri(uri).build().getQueryParams().get("param").get(0); - String fromUriString = UriComponentsBuilder.fromUriString(uri.toString()) + String fromUriString = UriComponentsBuilder.fromUriString(uri.toString(), parserType) .build().getQueryParams().get("param").get(0); assertThat(fromUriString).isEqualTo(fromUri); } - @Test - void fromUriString() { - UriComponents result = UriComponentsBuilder.fromUriString("https://www.ietf.org/rfc/rfc3986.txt").build(); + @ParameterizedTest + @EnumSource + void fromUriString(ParserType parserType) { + UriComponents result = UriComponentsBuilder.fromUriString("https://www.ietf.org/rfc/rfc3986.txt", parserType).build(); assertThat(result.getScheme()).isEqualTo("https"); assertThat(result.getUserInfo()).isNull(); assertThat(result.getHost()).isEqualTo("www.ietf.org"); @@ -175,7 +181,7 @@ void fromUriString() { String url = "https://arjen:foobar@java.sun.com:80" + "/javase/6/docs/api/java/util/BitSet.html?foo=bar#and(java.util.BitSet)"; - result = UriComponentsBuilder.fromUriString(url).build(); + result = UriComponentsBuilder.fromUriString(url, parserType).build(); assertThat(result.getScheme()).isEqualTo("https"); assertThat(result.getUserInfo()).isEqualTo("arjen:foobar"); assertThat(result.getHost()).isEqualTo("java.sun.com"); @@ -187,7 +193,7 @@ void fromUriString() { assertThat(result.getQueryParams()).isEqualTo(expectedQueryParams); assertThat(result.getFragment()).isEqualTo("and(java.util.BitSet)"); - result = UriComponentsBuilder.fromUriString("mailto:java-net@java.sun.com#baz").build(); + result = UriComponentsBuilder.fromUriString("mailto:java-net@java.sun.com#baz", parserType).build(); assertThat(result.getScheme()).isEqualTo("mailto"); assertThat(result.getUserInfo()).isNull(); assertThat(result.getHost()).isNull(); @@ -197,7 +203,17 @@ void fromUriString() { assertThat(result.getQuery()).isNull(); assertThat(result.getFragment()).isEqualTo("baz"); - result = UriComponentsBuilder.fromUriString("docs/guide/collections/designfaq.html#28").build(); + result = UriComponentsBuilder.fromUriString("mailto:user@example.com?subject=foo", parserType).build(); + assertThat(result.getScheme()).isEqualTo("mailto"); + assertThat(result.getUserInfo()).isNull(); + assertThat(result.getHost()).isNull(); + assertThat(result.getPort()).isEqualTo(-1); + assertThat(result.getSchemeSpecificPart()).isEqualTo("user@example.com?subject=foo"); + assertThat(result.getPath()).isNull(); + assertThat(result.getQuery()).isNull(); + assertThat(result.getFragment()).isNull(); + + result = UriComponentsBuilder.fromUriString("docs/guide/collections/designfaq.html#28", parserType).build(); assertThat(result.getScheme()).isNull(); assertThat(result.getUserInfo()).isNull(); assertThat(result.getHost()).isNull(); @@ -207,77 +223,80 @@ void fromUriString() { assertThat(result.getFragment()).isEqualTo("28"); } - @Test // SPR-9832 - void fromUriStringQueryParamWithReservedCharInValue() { + @ParameterizedTest // see SPR-9832 + @EnumSource + void fromUriStringQueryParamWithReservedCharInValue(ParserType parserType) { String uri = "https://www.google.com/ig/calculator?q=1USD=?EUR"; - UriComponents result = UriComponentsBuilder.fromUriString(uri).build(); + UriComponents result = UriComponentsBuilder.fromUriString(uri, parserType).build(); assertThat(result.getQuery()).isEqualTo("q=1USD=?EUR"); assertThat(result.getQueryParams().getFirst("q")).isEqualTo("1USD=?EUR"); } - @Test // SPR-14828 - void fromUriStringQueryParamEncodedAndContainingPlus() { + @ParameterizedTest // see SPR-14828 + @EnumSource + void fromUriStringQueryParamEncodedAndContainingPlus(ParserType parserType) { String httpUrl = "http://localhost:8080/test/print?value=%EA%B0%80+%EB%82%98"; - URI uri = UriComponentsBuilder.fromUriString(httpUrl).build(true).toUri(); + URI uri = UriComponentsBuilder.fromUriString(httpUrl, parserType).build(true).toUri(); assertThat(uri.toString()).isEqualTo(httpUrl); } - @Test // SPR-10539 - void fromUriStringIPv6Host() { + @ParameterizedTest // see SPR-10539 + @EnumSource + void fromUriStringIPv6Host(ParserType parserType) { UriComponents result = UriComponentsBuilder - .fromUriString("http://[1abc:2abc:3abc::5ABC:6abc]:8080/resource").build().encode(); - assertThat(result.getHost()).isEqualTo("[1abc:2abc:3abc::5ABC:6abc]"); - - UriComponents resultWithScopeId = UriComponentsBuilder - .fromUriString("http://[1abc:2abc:3abc::5ABC:6abc%eth0]:8080/resource").build().encode(); - assertThat(resultWithScopeId.getHost()).isEqualTo("[1abc:2abc:3abc::5ABC:6abc%25eth0]"); - - UriComponents resultIPv4compatible = UriComponentsBuilder - .fromUriString("http://[::192.168.1.1]:8080/resource").build().encode(); - assertThat(resultIPv4compatible.getHost()).isEqualTo("[::192.168.1.1]"); + .fromUriString("http://[1abc:2abc:3abc::5ABC:6abc]:8080/resource", parserType).build().encode(); + assertThat(result.getHost()).isEqualToIgnoringCase("[1abc:2abc:3abc::5ABC:6abc]"); } - @Test - void fromUriStringInvalidIPv6Host() { + @ParameterizedTest + @EnumSource + void fromUriStringInvalidIPv6Host(ParserType parserType) { assertThatIllegalArgumentException().isThrownBy(() -> - UriComponentsBuilder.fromUriString("http://[1abc:2abc:3abc::5ABC:6abc:8080/resource")); + UriComponentsBuilder.fromUriString("http://[1abc:2abc:3abc::5ABC:6abc:8080/resource", parserType)); } - @Test // SPR-11970 - void fromUriStringNoPathWithReservedCharInQuery() { - UriComponents result = UriComponentsBuilder.fromUriString("https://example.com?foo=bar@baz").build(); + @ParameterizedTest // see SPR-11970 + @EnumSource + void fromUriStringNoPathWithReservedCharInQuery(ParserType parserType) { + UriComponents result = UriComponentsBuilder.fromUriString("https://example.com?foo=bar@baz", parserType).build(); assertThat(result.getUserInfo()).isNull(); assertThat(result.getHost()).isEqualTo("example.com"); assertThat(result.getQueryParams()).containsKey("foo"); assertThat(result.getQueryParams().getFirst("foo")).isEqualTo("bar@baz"); } - @Test // SPR-14828 - void fromHttpUrlQueryParamEncodedAndContainingPlus() { + @ParameterizedTest // see SPR-1428 + @EnumSource + void fromHttpUrlQueryParamEncodedAndContainingPlus(ParserType parserType) { String httpUrl = "http://localhost:8080/test/print?value=%EA%B0%80+%EB%82%98"; - URI uri = UriComponentsBuilder.fromHttpUrl(httpUrl).build(true).toUri(); + URI uri = UriComponentsBuilder.fromUriString(httpUrl, parserType).build(true).toUri(); assertThat(uri.toString()).isEqualTo(httpUrl); } - @Test // SPR-10779 - void fromHttpUrlCaseInsensitiveScheme() { - assertThat(UriComponentsBuilder.fromHttpUrl("HTTP://www.google.com").build().getScheme()).isEqualTo("http"); - assertThat(UriComponentsBuilder.fromHttpUrl("HTTPS://www.google.com").build().getScheme()).isEqualTo("https"); + @ParameterizedTest // see SPR-10779 + @EnumSource + void fromHttpUrlCaseInsensitiveScheme(ParserType parserType) { + assertThat(UriComponentsBuilder.fromUriString("HTTP://www.google.com", parserType).build().getScheme()) + .isEqualTo("http"); + assertThat(UriComponentsBuilder.fromUriString("HTTPS://www.google.com", parserType).build().getScheme()) + .isEqualTo("https"); } - @Test // SPR-10539 - void fromHttpUrlInvalidIPv6Host() { + @ParameterizedTest // see SPR-10539 + @EnumSource + void fromHttpUrlInvalidIPv6Host(ParserType parserType) { assertThatIllegalArgumentException().isThrownBy(() -> - UriComponentsBuilder.fromHttpUrl("http://[1abc:2abc:3abc::5ABC:6abc:8080/resource")); + UriComponentsBuilder.fromUriString("http://[1abc:2abc:3abc::5ABC:6abc:8080/resource", parserType)); } - @Test - void fromHttpUrlWithoutFragment() { + @ParameterizedTest + @EnumSource + void fromHttpUrlWithoutFragment(ParserType parserType) { String httpUrl = "http://localhost:8080/test/print"; - UriComponents uriComponents = UriComponentsBuilder.fromHttpUrl(httpUrl).build(); + UriComponents uriComponents = UriComponentsBuilder.fromUriString(httpUrl, parserType).build(); assertThat(uriComponents.getScheme()).isEqualTo("http"); assertThat(uriComponents.getUserInfo()).isNull(); assertThat(uriComponents.getHost()).isEqualTo("localhost"); @@ -289,7 +308,7 @@ void fromHttpUrlWithoutFragment() { assertThat(uriComponents.toUri().toString()).isEqualTo(httpUrl); httpUrl = "http://user:test@localhost:8080/test/print?foo=bar"; - uriComponents = UriComponentsBuilder.fromHttpUrl(httpUrl).build(); + uriComponents = UriComponentsBuilder.fromUriString(httpUrl, parserType).build(); assertThat(uriComponents.getScheme()).isEqualTo("http"); assertThat(uriComponents.getUserInfo()).isEqualTo("user:test"); assertThat(uriComponents.getHost()).isEqualTo("localhost"); @@ -301,7 +320,7 @@ void fromHttpUrlWithoutFragment() { assertThat(uriComponents.toUri().toString()).isEqualTo(httpUrl); httpUrl = "http://localhost:8080/test/print?foo=bar"; - uriComponents = UriComponentsBuilder.fromHttpUrl(httpUrl).build(); + uriComponents = UriComponentsBuilder.fromUriString(httpUrl, parserType).build(); assertThat(uriComponents.getScheme()).isEqualTo("http"); assertThat(uriComponents.getUserInfo()).isNull(); assertThat(uriComponents.getHost()).isEqualTo("localhost"); @@ -313,22 +332,23 @@ void fromHttpUrlWithoutFragment() { assertThat(uriComponents.toUri().toString()).isEqualTo(httpUrl); } - @Test // gh-25300 - void fromHttpUrlWithFragment() { - String httpUrl = "https://example.com#baz"; - UriComponents uriComponents = UriComponentsBuilder.fromHttpUrl(httpUrl).build(); + @ParameterizedTest // see gh-25300 + @EnumSource + void fromHttpUrlWithFragment(ParserType parserType) { + String httpUrl = "https://example.com/#baz"; + UriComponents uriComponents = UriComponentsBuilder.fromUriString(httpUrl, parserType).build(); assertThat(uriComponents.getScheme()).isEqualTo("https"); assertThat(uriComponents.getUserInfo()).isNull(); assertThat(uriComponents.getHost()).isEqualTo("example.com"); assertThat(uriComponents.getPort()).isEqualTo(-1); - assertThat(uriComponents.getPath()).isNullOrEmpty(); + assertThat(uriComponents.getPath()).isEqualTo("/"); assertThat(uriComponents.getPathSegments()).isEmpty(); assertThat(uriComponents.getQuery()).isNull(); assertThat(uriComponents.getFragment()).isEqualTo("baz"); assertThat(uriComponents.toUri().toString()).isEqualTo(httpUrl); httpUrl = "http://localhost:8080/test/print#baz"; - uriComponents = UriComponentsBuilder.fromHttpUrl(httpUrl).build(); + uriComponents = UriComponentsBuilder.fromUriString(httpUrl, parserType).build(); assertThat(uriComponents.getScheme()).isEqualTo("http"); assertThat(uriComponents.getUserInfo()).isNull(); assertThat(uriComponents.getHost()).isEqualTo("localhost"); @@ -340,7 +360,7 @@ void fromHttpUrlWithFragment() { assertThat(uriComponents.toUri().toString()).isEqualTo(httpUrl); httpUrl = "http://localhost:8080/test/print?foo=bar#baz"; - uriComponents = UriComponentsBuilder.fromHttpUrl(httpUrl).build(); + uriComponents = UriComponentsBuilder.fromUriString(httpUrl, parserType).build(); assertThat(uriComponents.getScheme()).isEqualTo("http"); assertThat(uriComponents.getUserInfo()).isNull(); assertThat(uriComponents.getHost()).isEqualTo("localhost"); @@ -428,30 +448,32 @@ void pathWithDuplicateSlashes() { assertThat(uriComponents.getPath()).isEqualTo("/foo/bar"); } - @Test - void replacePath() { - UriComponentsBuilder builder = UriComponentsBuilder.fromUriString("https://www.ietf.org/rfc/rfc2396.txt"); + @ParameterizedTest + @EnumSource + void replacePath(ParserType parserType) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString("https://www.ietf.org/rfc/rfc2396.txt", parserType); builder.replacePath("/rfc/rfc3986.txt"); UriComponents result = builder.build(); assertThat(result.toUriString()).isEqualTo("https://www.ietf.org/rfc/rfc3986.txt"); - builder = UriComponentsBuilder.fromUriString("https://www.ietf.org/rfc/rfc2396.txt"); + builder = UriComponentsBuilder.fromUriString("https://www.ietf.org/rfc/rfc2396.txt", parserType); builder.replacePath(null); result = builder.build(); assertThat(result.toUriString()).isEqualTo("https://www.ietf.org"); } - @Test - void replaceQuery() { - UriComponentsBuilder builder = UriComponentsBuilder.fromUriString("https://example.com/foo?foo=bar&baz=qux"); + @ParameterizedTest + @EnumSource + void replaceQuery(ParserType parserType) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString("https://example.com/foo?foo=bar&baz=qux", parserType); builder.replaceQuery("baz=42"); UriComponents result = builder.build(); assertThat(result.toUriString()).isEqualTo("https://example.com/foo?baz=42"); - builder = UriComponentsBuilder.fromUriString("https://example.com/foo?foo=bar&baz=qux"); + builder = UriComponentsBuilder.fromUriString("https://example.com/foo?foo=bar&baz=qux", parserType); builder.replaceQuery(null); result = builder.build(); @@ -582,36 +604,68 @@ void buildAndExpandHierarchical() { assertThat(result.toUriString()).isEqualTo("/fooValue/barValue"); } - @Test - void buildAndExpandOpaque() { - UriComponents result = UriComponentsBuilder.fromUriString("mailto:{user}@{domain}") + @ParameterizedTest + @EnumSource + void parseBuildAndExpandHierarchical(ParserType parserType) { + URI uri = UriComponentsBuilder + .fromUriString("{scheme}://{host}:{port}/{segment}?{query}#{fragment}", parserType) + .buildAndExpand(Map.of( + "scheme", "ws", "host", "example.org", "port", "7777", "segment", "path", + "query", "q=1", "fragment", "foo")) + .toUri(); + assertThat(uri.toString()).isEqualTo("ws://example.org:7777/path?q=1#foo"); + } + + @ParameterizedTest + @EnumSource + void buildAndExpandOpaque(ParserType parserType) { + UriComponents result = UriComponentsBuilder.fromUriString("mailto:{user}@{domain}", parserType) .buildAndExpand("foo", "example.com"); assertThat(result.toUriString()).isEqualTo("mailto:foo@example.com"); Map values = new HashMap<>(); values.put("user", "foo"); values.put("domain", "example.com"); - UriComponentsBuilder.fromUriString("mailto:{user}@{domain}").buildAndExpand(values); + UriComponentsBuilder.fromUriString("mailto:{user}@{domain}", parserType).buildAndExpand(values); assertThat(result.toUriString()).isEqualTo("mailto:foo@example.com"); } - @Test - void queryParamWithValueWithEquals() { - UriComponents uriComponents = UriComponentsBuilder.fromUriString("https://example.com/foo?bar=baz").build(); + @ParameterizedTest // gh-33699 + @EnumSource + void schemeVariableMixedCase(ParserType parserType) { + + BiConsumer tester = (scheme, value) -> { + URI uri = UriComponentsBuilder.fromUriString(scheme + "://example.org", parserType) + .buildAndExpand(Map.of("TheScheme", value)) + .toUri(); + assertThat(uri.toString()).isEqualTo("wss://example.org"); + }; + + tester.accept("{TheScheme}", "wss"); + tester.accept("{TheScheme}s", "ws"); + tester.accept("ws{TheScheme}", "s"); + } + + @ParameterizedTest + @EnumSource + void queryParamWithValueWithEquals(ParserType parserType) { + UriComponents uriComponents = UriComponentsBuilder.fromUriString("https://example.com/foo?bar=baz", parserType).build(); assertThat(uriComponents.toUriString()).isEqualTo("https://example.com/foo?bar=baz"); assertThat(uriComponents.getQueryParams().get("bar")).containsExactly("baz"); } - @Test - void queryParamWithoutValueWithEquals() { - UriComponents uriComponents = UriComponentsBuilder.fromUriString("https://example.com/foo?bar=").build(); + @ParameterizedTest + @EnumSource + void queryParamWithoutValueWithEquals(ParserType parserType) { + UriComponents uriComponents = UriComponentsBuilder.fromUriString("https://example.com/foo?bar=", parserType).build(); assertThat(uriComponents.toUriString()).isEqualTo("https://example.com/foo?bar="); assertThat(uriComponents.getQueryParams().get("bar")).element(0).asString().isEmpty(); } - @Test - void queryParamWithoutValueWithoutEquals() { - UriComponents uriComponents = UriComponentsBuilder.fromUriString("https://example.com/foo?bar").build(); + @ParameterizedTest + @EnumSource + void queryParamWithoutValueWithoutEquals(ParserType parserType) { + UriComponents uriComponents = UriComponentsBuilder.fromUriString("https://example.com/foo?bar", parserType).build(); assertThat(uriComponents.toUriString()).isEqualTo("https://example.com/foo?bar"); // TODO [SPR-13537] Change equalTo(null) to equalTo(""). @@ -633,52 +687,50 @@ void opaqueUriDoesNotResetOnNullInput() { assertThat(result.toUri()).isEqualTo(uri); } - @Test - void relativeUrls() { + @ParameterizedTest + @EnumSource + void relativeUrls(ParserType parserType) { String baseUrl = "https://example.com"; - assertThat(UriComponentsBuilder.fromUriString(baseUrl + "/foo/../bar").build().toString()) - .isEqualTo(baseUrl + "/foo/../bar"); - assertThat(UriComponentsBuilder.fromUriString(baseUrl + "/foo/../bar").build().toUriString()) - .isEqualTo(baseUrl + "/foo/../bar"); - assertThat(UriComponentsBuilder.fromUriString(baseUrl + "/foo/../bar").build().toUri().getPath()) - .isEqualTo("/foo/../bar"); - assertThat(UriComponentsBuilder.fromUriString("../../").build().toString()) - .isEqualTo("../../"); - assertThat(UriComponentsBuilder.fromUriString("../../").build().toUriString()) - .isEqualTo("../../"); - assertThat(UriComponentsBuilder.fromUriString("../../").build().toUri().getPath()) - .isEqualTo("../../"); - assertThat(UriComponentsBuilder.fromUriString(baseUrl).path("foo/../bar").build().toString()) + assertThat(UriComponentsBuilder.fromUriString(baseUrl + "/foo/../bar", parserType).build().toString()) + .isEqualTo(baseUrl + (parserType == ParserType.WHAT_WG ? "/bar" : "/foo/../bar")); + assertThat(UriComponentsBuilder.fromUriString(baseUrl + "/foo/../bar", parserType).build().toUriString()) + .isEqualTo(baseUrl + (parserType == ParserType.WHAT_WG ? "/bar" : "/foo/../bar")); + assertThat(UriComponentsBuilder.fromUriString(baseUrl + "/foo/../bar", parserType).build().toUri().getPath()) + .isEqualTo((parserType == ParserType.WHAT_WG ? "/bar" : "/foo/../bar")); + assertThat(UriComponentsBuilder.fromUriString(baseUrl, parserType).path("foo/../bar").build().toString()) .isEqualTo(baseUrl + "/foo/../bar"); - assertThat(UriComponentsBuilder.fromUriString(baseUrl).path("foo/../bar").build().toUriString()) + assertThat(UriComponentsBuilder.fromUriString(baseUrl, parserType).path("foo/../bar").build().toUriString()) .isEqualTo(baseUrl + "/foo/../bar"); - assertThat(UriComponentsBuilder.fromUriString(baseUrl).path("foo/../bar").build().toUri().getPath()) + assertThat(UriComponentsBuilder.fromUriString(baseUrl, parserType).path("foo/../bar").build().toUri().getPath()) .isEqualTo("/foo/../bar"); } - @Test - void emptySegments() { + @ParameterizedTest + @EnumSource + void emptySegments(ParserType parserType) { String baseUrl = "https://example.com/abc/"; - assertThat(UriComponentsBuilder.fromUriString(baseUrl).path("/x/y/z").build().toString()) + assertThat(UriComponentsBuilder.fromUriString(baseUrl, parserType).path("/x/y/z").build().toString()) .isEqualTo("https://example.com/abc/x/y/z"); - assertThat(UriComponentsBuilder.fromUriString(baseUrl).pathSegment("x", "y", "z").build().toString()) + assertThat(UriComponentsBuilder.fromUriString(baseUrl, parserType).pathSegment("x", "y", "z").build().toString()) .isEqualTo("https://example.com/abc/x/y/z"); - assertThat(UriComponentsBuilder.fromUriString(baseUrl).path("/x/").path("/y/z").build().toString()) + assertThat(UriComponentsBuilder.fromUriString(baseUrl, parserType).path("/x/").path("/y/z").build().toString()) .isEqualTo("https://example.com/abc/x/y/z"); - assertThat(UriComponentsBuilder.fromUriString(baseUrl).pathSegment("x").path("y").build().toString()) + assertThat(UriComponentsBuilder.fromUriString(baseUrl, parserType).pathSegment("x").path("y").build().toString()) .isEqualTo("https://example.com/abc/x/y"); } - @Test - void parsesEmptyFragment() { - UriComponents components = UriComponentsBuilder.fromUriString("/example#").build(); + @ParameterizedTest + @EnumSource + void parsesEmptyFragment(ParserType parserType) { + UriComponents components = UriComponentsBuilder.fromUriString("/example#", parserType).build(); assertThat(components.getFragment()).isNull(); assertThat(components.toString()).isEqualTo("/example"); } - @Test // SPR-13257 - void parsesEmptyUri() { - UriComponents components = UriComponentsBuilder.fromUriString("").build(); + @ParameterizedTest // SPR-13257 + @EnumSource + void parsesEmptyUri(ParserType parserType) { + UriComponents components = UriComponentsBuilder.fromUriString("", parserType).build(); assertThat(components.toString()).isEmpty(); } @@ -738,17 +790,18 @@ void testDeepClone() { assertThat(result1.getSchemeSpecificPart()).isNull(); } - @Test // gh-26466 - void encodeTemplateWithInvalidPlaceholderSyntax() { + @ParameterizedTest // gh-26466 + @EnumSource + void encodeTemplateWithInvalidPlaceholderSyntax(ParserType parserType) { BiConsumer tester = (in, out) -> - assertThat(UriComponentsBuilder.fromUriString(in).encode().toUriString()).isEqualTo(out); + assertThat(UriComponentsBuilder.fromUriString(in, parserType).encode().toUriString()).isEqualTo(out); // empty tester.accept("{}", "%7B%7D"); - tester.accept("{ \t}", "%7B%20%09%7D"); + tester.accept("{ \t}", (parserType == ParserType.WHAT_WG ? "%7B%20%7D" : "%7B%20%09%7D")); tester.accept("/a{}b", "/a%7B%7Db"); - tester.accept("/a{ \t}b", "/a%7B%20%09%7Db"); + tester.accept("/a{ \t}b", (parserType == ParserType.WHAT_WG ? "/a%7B%20%7Db" : "/a%7B%20%09%7Db")); // nested, matching tester.accept("{foo{}}", "%7Bfoo%7B%7D%7D"); @@ -767,28 +820,32 @@ void encodeTemplateWithInvalidPlaceholderSyntax() { tester.accept("/a{year:\\d{1,4}}b", "/a{year:\\d{1,4}}b"); } - @Test // SPR-16364 - void uriComponentsNotEqualAfterNormalization() { - UriComponents uri1 = UriComponentsBuilder.fromUriString("http://test.com").build().normalize(); - UriComponents uri2 = UriComponentsBuilder.fromUriString("http://test.com/").build(); + @ParameterizedTest // SPR-16364 + @EnumSource + void uriComponentsNotEqualAfterNormalization(ParserType parserType) { + UriComponents uri1 = UriComponentsBuilder.fromUriString("http://test.com", parserType).build().normalize(); + UriComponents uri2 = UriComponentsBuilder.fromUriString("http://test.com/", parserType).build(); assertThat(uri1.getPathSegments()).isEmpty(); assertThat(uri2.getPathSegments()).isEmpty(); assertThat(uri2).isNotEqualTo(uri1); } - @Test // SPR-17256 - void uriComponentsWithMergedQueryParams() { - String uri = UriComponentsBuilder.fromUriString("http://localhost:8081") - .uriComponents(UriComponentsBuilder.fromUriString("/{path}?sort={sort}").build()) + @ParameterizedTest // SPR-17256 + @EnumSource + void uriComponentsWithMergedQueryParams(ParserType parserType) { + String uri = UriComponentsBuilder.fromUriString("http://localhost:8081", parserType) + .uriComponents(UriComponentsBuilder.fromUriString("/{path}?sort={sort}", parserType).build()) .queryParam("sort", "another_value").build().toString(); assertThat(uri).isEqualTo("http://localhost:8081/{path}?sort={sort}&sort=another_value"); } - @Test // SPR-17630 - void toUriStringWithCurlyBraces() { - assertThat(UriComponentsBuilder.fromUriString("/path?q={asa}asa").toUriString()).isEqualTo("/path?q=%7Basa%7Dasa"); + @ParameterizedTest // SPR-17630 + @EnumSource + void toUriStringWithCurlyBraces(ParserType parserType) { + assertThat(UriComponentsBuilder.fromUriString("/path?q={asa}asa", parserType).toUriString()) + .isEqualTo("/path?q=%7Basa%7Dasa"); } @Test // gh-26012 @@ -797,37 +854,38 @@ void verifyDoubleSlashReplacedWithSingleOne() { assertThat(path).isEqualTo("/home/path"); } - @Test - void validPort() { - UriComponents uriComponents = UriComponentsBuilder.fromUriString("http://localhost:52567/path").build(); + @ParameterizedTest + @EnumSource + void validPort(ParserType parserType) { + UriComponents uriComponents = UriComponentsBuilder.fromUriString("http://localhost:52567/path", parserType).build(); assertThat(uriComponents.getPort()).isEqualTo(52567); assertThat(uriComponents.getPath()).isEqualTo("/path"); - uriComponents = UriComponentsBuilder.fromUriString("http://localhost:52567?trace=false").build(); + uriComponents = UriComponentsBuilder.fromUriString("http://localhost:52567?trace=false", parserType).build(); assertThat(uriComponents.getPort()).isEqualTo(52567); assertThat(uriComponents.getQuery()).isEqualTo("trace=false"); - uriComponents = UriComponentsBuilder.fromUriString("http://localhost:52567#fragment").build(); + uriComponents = UriComponentsBuilder.fromUriString("http://localhost:52567#fragment", parserType).build(); assertThat(uriComponents.getPort()).isEqualTo(52567); assertThat(uriComponents.getFragment()).isEqualTo("fragment"); } - @Test - void verifyInvalidPort() { + @ParameterizedTest + @EnumSource + void verifyInvalidPort(ParserType parserType) { String url = "http://localhost:XXX/path"; - assertThatIllegalStateException() - .isThrownBy(() -> UriComponentsBuilder.fromUriString(url).build().toUri()) - .withMessage("The port must be an integer: XXX"); - assertThatIllegalStateException() - .isThrownBy(() -> UriComponentsBuilder.fromHttpUrl(url).build().toUri()) - .withMessage("The port must be an integer: XXX"); + assertThatIllegalArgumentException() + .isThrownBy(() -> UriComponentsBuilder.fromUriString(url, parserType).build().toUri()); + assertThatIllegalArgumentException() + .isThrownBy(() -> UriComponentsBuilder.fromUriString(url, parserType).build().toUri()); } - @Test // gh-27039 - void expandPortAndPathWithoutSeparator() { + @ParameterizedTest // gh-27039 + @EnumSource + void expandPortAndPathWithoutSeparator(ParserType parserType) { URI uri = UriComponentsBuilder - .fromUriString("ws://localhost:{port}{path}") - .buildAndExpand(7777, "/test") + .fromUriString("ws://localhost:{port}/{path}", parserType) + .buildAndExpand(7777, "test") .toUri(); assertThat(uri.toString()).isEqualTo("ws://localhost:7777/test"); } diff --git a/spring-web/src/test/java/org/springframework/web/util/UriComponentsTests.java b/spring-web/src/test/java/org/springframework/web/util/UriComponentsTests.java index 1415ab29c5ac..33fcbbe27a4e 100644 --- a/spring-web/src/test/java/org/springframework/web/util/UriComponentsTests.java +++ b/spring-web/src/test/java/org/springframework/web/util/UriComponentsTests.java @@ -25,11 +25,15 @@ import java.util.Collections; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import org.springframework.web.util.UriComponentsBuilder.ParserType; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; -import static org.springframework.web.util.UriComponentsBuilder.fromHttpUrl; import static org.springframework.web.util.UriComponentsBuilder.fromUriString; /** @@ -74,78 +78,88 @@ void encodeAndExpandWithDollarSign() { assertThat(uri.expand("JavaClass$1.class").toString()).isEqualTo("/path?q=JavaClass%241.class"); } - @Test - void toUriEncoded() { - UriComponents uri = UriComponentsBuilder.fromUriString("https://example.com/hotel list/Z\u00fcrich").build(); + @ParameterizedTest + @EnumSource + void toUriEncoded(ParserType parserType) { + UriComponents uri = UriComponentsBuilder.fromUriString("https://example.com/hotel list/Z\u00fcrich", parserType).build(); assertThat(uri.encode().toUri()).isEqualTo(URI.create("https://example.com/hotel%20list/Z%C3%BCrich")); } - @Test - void toUriNotEncoded() { - UriComponents uri = UriComponentsBuilder.fromUriString("https://example.com/hotel list/Z\u00fcrich").build(); + @ParameterizedTest + @EnumSource + void toUriNotEncoded(ParserType parserType) { + UriComponents uri = UriComponentsBuilder.fromUriString("https://example.com/hotel list/Z\u00fcrich", parserType).build(); assertThat(uri.toUri()).isEqualTo(URI.create("https://example.com/hotel%20list/Z\u00fcrich")); } - @Test - void toUriAlreadyEncoded() { - UriComponents uri = UriComponentsBuilder.fromUriString("https://example.com/hotel%20list/Z%C3%BCrich").build(true); + @ParameterizedTest + @EnumSource + void toUriAlreadyEncoded(ParserType parserType) { + UriComponents uri = UriComponentsBuilder.fromUriString("https://example.com/hotel%20list/Z%C3%BCrich", parserType).build(true); assertThat(uri.encode().toUri()).isEqualTo(URI.create("https://example.com/hotel%20list/Z%C3%BCrich")); } - @Test - void toUriWithIpv6HostAlreadyEncoded() { + @ParameterizedTest + @EnumSource + void toUriWithIpv6HostAlreadyEncoded(ParserType parserType) { UriComponents uri = UriComponentsBuilder.fromUriString( - "http://[1abc:2abc:3abc::5ABC:6abc]:8080/hotel%20list/Z%C3%BCrich").build(true); + "http://[1abc:2abc:3abc::5ABC:6abc]:8080/hotel%20list/Z%C3%BCrich", parserType).build(true); assertThat(uri.encode().toUri()).isEqualTo( URI.create("http://[1abc:2abc:3abc::5ABC:6abc]:8080/hotel%20list/Z%C3%BCrich")); } - @Test - void toUriStringWithPortVariable() { + @ParameterizedTest + @EnumSource + void toUriStringWithPortVariable(ParserType parserType) { String url = "http://localhost:{port}/first"; - assertThat(UriComponentsBuilder.fromUriString(url).build().toUriString()).isEqualTo(url); + assertThat(UriComponentsBuilder.fromUriString(url, parserType).build().toUriString()).isEqualTo(url); } - @Test - void expand() { - UriComponents uri = UriComponentsBuilder.fromUriString("https://example.com").path("/{foo} {bar}").build(); + @ParameterizedTest + @EnumSource + void expand(ParserType parserType) { + UriComponents uri = UriComponentsBuilder.fromUriString("https://example.com", parserType).path("/{foo} {bar}").build(); uri = uri.expand("1 2", "3 4"); assertThat(uri.getPath()).isEqualTo("/1 2 3 4"); assertThat(uri.toUriString()).isEqualTo("https://example.com/1 2 3 4"); } - @Test // SPR-13311 - void expandWithRegexVar() { + @ParameterizedTest // SPR-13311 + @EnumSource + void expandWithRegexVar(ParserType parserType) { String template = "/myurl/{name:[a-z]{1,5}}/show"; - UriComponents uri = UriComponentsBuilder.fromUriString(template).build(); + UriComponents uri = UriComponentsBuilder.fromUriString(template, parserType).build(); uri = uri.expand(Collections.singletonMap("name", "test")); assertThat(uri.getPath()).isEqualTo("/myurl/test/show"); } - @Test // SPR-17630 - void uirTemplateExpandWithMismatchedCurlyBraces() { - UriComponents uri = UriComponentsBuilder.fromUriString("/myurl/?q={{{{").encode().build(); + @ParameterizedTest // SPR-17630 + @EnumSource + void uirTemplateExpandWithMismatchedCurlyBraces(ParserType parserType) { + UriComponents uri = UriComponentsBuilder.fromUriString("/myurl/?q={{{{", parserType).encode().build(); assertThat(uri.toUriString()).isEqualTo("/myurl/?q=%7B%7B%7B%7B"); } - @Test // gh-22447 - void expandWithFragmentOrder() { + @ParameterizedTest // gh-22447 + @EnumSource + void expandWithFragmentOrder(ParserType parserType) { UriComponents uri = UriComponentsBuilder - .fromUriString("https://{host}/{path}#{fragment}").build() + .fromUriString("https://{host}/{path}#{fragment}", parserType).build() .expand("example.com", "foo", "bar"); assertThat(uri.toUriString()).isEqualTo("https://example.com/foo#bar"); } - @Test // SPR-12123 - void port() { - UriComponents uri1 = fromUriString("https://example.com:8080/bar").build(); - UriComponents uri2 = fromUriString("https://example.com/bar").port(8080).build(); - UriComponents uri3 = fromUriString("https://example.com/bar").port("{port}").build().expand(8080); - UriComponents uri4 = fromUriString("https://example.com/bar").port("808{digit}").build().expand(0); + @ParameterizedTest // SPR-12123 + @EnumSource + void port(ParserType parserType) { + UriComponents uri1 = fromUriString("https://example.com:8080/bar", parserType).build(); + UriComponents uri2 = fromUriString("https://example.com/bar", parserType).port(8080).build(); + UriComponents uri3 = fromUriString("https://example.com/bar", parserType).port("{port}").build().expand(8080); + UriComponents uri4 = fromUriString("https://example.com/bar", parserType).port("808{digit}").build().expand(0); assertThat(uri1.getPort()).isEqualTo(8080); assertThat(uri1.toUriString()).isEqualTo("https://example.com:8080/bar"); @@ -157,12 +171,12 @@ void port() { assertThat(uri4.toUriString()).isEqualTo("https://example.com:8080/bar"); } - @Test // gh-28521 - void invalidPort() { - assertExceptionsForInvalidPort(fromUriString("https://example.com:XXX/bar").build()); - assertExceptionsForInvalidPort(fromUriString("https://example.com/bar").port("XXX").build()); - assertExceptionsForInvalidPort(fromHttpUrl("https://example.com:XXX/bar").build()); - assertExceptionsForInvalidPort(fromHttpUrl("https://example.com/bar").port("XXX").build()); + @ParameterizedTest // gh-28521 + @EnumSource + void invalidPort(ParserType parserType) { + assertThatExceptionOfType(InvalidUrlException.class) + .isThrownBy(() -> fromUriString("https://example.com:XXX/bar", parserType)); + assertExceptionsForInvalidPort(fromUriString("https://example.com/bar", parserType).port("XXX").build()); } private void assertExceptionsForInvalidPort(UriComponents uriComponents) { @@ -192,16 +206,18 @@ void invalidEncodedSequence() { UriComponentsBuilder.fromPath("/fo%2o").build(true)); } - @Test - void normalize() { - UriComponents uri = UriComponentsBuilder.fromUriString("https://example.com/foo/../bar").build(); + @ParameterizedTest + @EnumSource + void normalize(ParserType parserType) { + UriComponents uri = UriComponentsBuilder.fromUriString("https://example.com/foo/../bar", parserType).build(); assertThat(uri.normalize().toString()).isEqualTo("https://example.com/bar"); } - @Test - void serializable() throws Exception { + @ParameterizedTest + @EnumSource + void serializable(ParserType parserType) throws Exception { UriComponents uri = UriComponentsBuilder.fromUriString( - "https://example.com").path("/{foo}").query("bar={baz}").build(); + "https://example.com", parserType).path("/{foo}").query("bar={baz}").build(); ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos); @@ -223,12 +239,13 @@ void copyToUriComponentsBuilder() { assertThat(result.getPathSegments()).isEqualTo(Arrays.asList("foo", "bar", "ba%2Fz")); } - @Test - void equalsHierarchicalUriComponents() { + @ParameterizedTest + @EnumSource + void equalsHierarchicalUriComponents(ParserType parserType) { String url = "https://example.com"; - UriComponents uric1 = UriComponentsBuilder.fromUriString(url).path("/{foo}").query("bar={baz}").build(); - UriComponents uric2 = UriComponentsBuilder.fromUriString(url).path("/{foo}").query("bar={baz}").build(); - UriComponents uric3 = UriComponentsBuilder.fromUriString(url).path("/{foo}").query("bin={baz}").build(); + UriComponents uric1 = UriComponentsBuilder.fromUriString(url, parserType).path("/{foo}").query("bar={baz}").build(); + UriComponents uric2 = UriComponentsBuilder.fromUriString(url, parserType).path("/{foo}").query("bar={baz}").build(); + UriComponents uric3 = UriComponentsBuilder.fromUriString(url, parserType).path("/{foo}").query("bin={baz}").build(); assertThat(uric1).isInstanceOf(HierarchicalUriComponents.class); assertThat(uric1).isEqualTo(uric1); @@ -236,14 +253,14 @@ void equalsHierarchicalUriComponents() { assertThat(uric1).isNotEqualTo(uric3); } - @Test - void equalsOpaqueUriComponents() { + @ParameterizedTest + @EnumSource + void equalsOpaqueUriComponents(ParserType parserType) { String baseUrl = "http:example.com"; - UriComponents uric1 = UriComponentsBuilder.fromUriString(baseUrl + "/foo/bar").build(); - UriComponents uric2 = UriComponentsBuilder.fromUriString(baseUrl + "/foo/bar").build(); - UriComponents uric3 = UriComponentsBuilder.fromUriString(baseUrl + "/foo/bin").build(); + UriComponents uric1 = UriComponentsBuilder.fromUriString(baseUrl + "/foo/bar", parserType).build(); + UriComponents uric2 = UriComponentsBuilder.fromUriString(baseUrl + "/foo/bar", parserType).build(); + UriComponents uric3 = UriComponentsBuilder.fromUriString(baseUrl + "/foo/bin", parserType).build(); - assertThat(uric1).isInstanceOf(OpaqueUriComponents.class); assertThat(uric1).isEqualTo(uric1); assertThat(uric1).isEqualTo(uric2); assertThat(uric1).isNotEqualTo(uric3); diff --git a/spring-web/src/test/java/org/springframework/web/util/UriTemplateTests.java b/spring-web/src/test/java/org/springframework/web/util/UriTemplateTests.java index 54c0640bfc07..e95050751ba1 100644 --- a/spring-web/src/test/java/org/springframework/web/util/UriTemplateTests.java +++ b/spring-web/src/test/java/org/springframework/web/util/UriTemplateTests.java @@ -17,9 +17,6 @@ package org.springframework.web.util; import java.net.URI; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -50,7 +47,7 @@ void nullPathThrowsException() { void getVariableNames() { UriTemplate template = new UriTemplate("/hotels/{hotel}/bookings/{booking}"); List variableNames = template.getVariableNames(); - assertThat(variableNames).as("Invalid variable names").isEqualTo(Arrays.asList("hotel", "booking")); + assertThat(variableNames).as("Invalid variable names").containsExactly("hotel", "booking"); } @Test @@ -72,6 +69,9 @@ void expandVarArgsFromEmpty() { UriTemplate template = new UriTemplate(""); URI result = template.expand(); assertThat(result).as("Invalid expanded template").isEqualTo(URI.create("")); + + result = template.expand("1", "42"); + assertThat(result).as("Invalid expanded template").isEqualTo(URI.create("")); } @Test // SPR-9712 @@ -89,9 +89,7 @@ void expandVarArgsNotEnoughVariables() { @Test void expandMap() { - Map uriVariables = new HashMap<>(2); - uriVariables.put("booking", "42"); - uriVariables.put("hotel", "1"); + Map uriVariables = Map.of("booking", "42", "hotel", "1"); UriTemplate template = new UriTemplate("/hotels/{hotel}/bookings/{booking}"); URI result = template.expand(uriVariables); assertThat(result).as("Invalid expanded template").isEqualTo(URI.create("/hotels/1/bookings/42")); @@ -100,16 +98,14 @@ void expandMap() { @Test void expandMapDuplicateVariables() { UriTemplate template = new UriTemplate("/order/{c}/{c}/{c}"); - assertThat(template.getVariableNames()).isEqualTo(Arrays.asList("c", "c", "c")); - URI result = template.expand(Collections.singletonMap("c", "cheeseburger")); + assertThat(template.getVariableNames()).containsExactly("c", "c", "c"); + URI result = template.expand(Map.of("c", "cheeseburger")); assertThat(result).isEqualTo(URI.create("/order/cheeseburger/cheeseburger/cheeseburger")); } @Test void expandMapNonString() { - Map uriVariables = new HashMap<>(2); - uriVariables.put("booking", 42); - uriVariables.put("hotel", 1); + Map uriVariables = Map.of("booking", 42, "hotel", 1); UriTemplate template = new UriTemplate("/hotels/{hotel}/bookings/{booking}"); URI result = template.expand(uriVariables); assertThat(result).as("Invalid expanded template").isEqualTo(URI.create("/hotels/1/bookings/42")); @@ -117,7 +113,7 @@ void expandMapNonString() { @Test void expandMapEncoded() { - Map uriVariables = Collections.singletonMap("hotel", "Z\u00fcrich"); + Map uriVariables = Map.of("hotel", "Z\u00fcrich"); UriTemplate template = new UriTemplate("/hotel list/{hotel}"); URI result = template.expand(uriVariables); assertThat(result).as("Invalid expanded template").isEqualTo(URI.create("/hotel%20list/Z%C3%BCrich")); @@ -125,12 +121,9 @@ void expandMapEncoded() { @Test void expandMapUnboundVariables() { - Map uriVariables = new HashMap<>(2); - uriVariables.put("booking", "42"); - uriVariables.put("bar", "1"); + Map uriVariables = Map.of("booking", "42", "bar", "1"); UriTemplate template = new UriTemplate("/hotels/{hotel}/bookings/{booking}"); - assertThatIllegalArgumentException().isThrownBy(() -> - template.expand(uriVariables)); + assertThatIllegalArgumentException().isThrownBy(() -> template.expand(uriVariables)); } @Test @@ -167,9 +160,7 @@ void matchesCustomRegex() { @Test void match() { - Map expected = new HashMap<>(2); - expected.put("booking", "42"); - expected.put("hotel", "1"); + Map expected = Map.of("booking", "42", "hotel", "1"); UriTemplate template = new UriTemplate("/hotels/{hotel}/bookings/{booking}"); Map result = template.match("/hotels/1/bookings/42"); @@ -185,9 +176,7 @@ void matchAgainstEmpty() { @Test void matchCustomRegex() { - Map expected = new HashMap<>(2); - expected.put("booking", "42"); - expected.put("hotel", "1"); + Map expected = Map.of("booking", "42", "hotel", "1"); UriTemplate template = new UriTemplate("/hotels/{hotel:\\d}/bookings/{booking:\\d+}"); Map result = template.match("/hotels/1/bookings/42"); @@ -198,24 +187,21 @@ void matchCustomRegex() { void matchCustomRegexWithNestedCurlyBraces() { UriTemplate template = new UriTemplate("/site.{domain:co.[a-z]{2}}"); Map result = template.match("/site.co.eu"); - assertThat(result).as("Invalid match").isEqualTo(Collections.singletonMap("domain", "co.eu")); + assertThat(result).as("Invalid match").isEqualTo(Map.of("domain", "co.eu")); } @Test void matchDuplicate() { UriTemplate template = new UriTemplate("/order/{c}/{c}/{c}"); Map result = template.match("/order/cheeseburger/cheeseburger/cheeseburger"); - Map expected = Collections.singletonMap("c", "cheeseburger"); - assertThat(result).as("Invalid match").isEqualTo(expected); + assertThat(result).as("Invalid match").isEqualTo(Map.of("c", "cheeseburger")); } @Test void matchMultipleInOneSegment() { UriTemplate template = new UriTemplate("/{foo}-{bar}"); Map result = template.match("/12-34"); - Map expected = new HashMap<>(2); - expected.put("foo", "12"); - expected.put("bar", "34"); + Map expected = Map.of("foo", "12", "bar", "34"); assertThat(result).as("Invalid match").isEqualTo(expected); } @@ -249,14 +235,14 @@ void matchesWithSlashAtTheEnd() { void expandWithDollar() { UriTemplate template = new UriTemplate("/{a}"); URI uri = template.expand("$replacement"); - assertThat(uri.toString()).isEqualTo("/$replacement"); + assertThat(uri).hasToString("/$replacement"); } @Test void expandWithAtSign() { UriTemplate template = new UriTemplate("http://localhost/query={query}"); URI uri = template.expand("foo@bar"); - assertThat(uri.toString()).isEqualTo("http://localhost/query=foo@bar"); + assertThat(uri).hasToString("http://localhost/query=foo@bar"); } } diff --git a/spring-web/src/test/java/org/springframework/web/util/WhatWgUrlParserTests.java b/spring-web/src/test/java/org/springframework/web/util/WhatWgUrlParserTests.java new file mode 100644 index 000000000000..4ea6c2182557 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/util/WhatWgUrlParserTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.util; + +import org.junit.jupiter.api.Test; + +import org.springframework.lang.Nullable; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Arjen Poutsma + */ +class WhatWgUrlParserTests { + + private static final WhatWgUrlParser.UrlRecord EMPTY_URL_RECORD = new WhatWgUrlParser.UrlRecord(); + + @Test + void parse() { + testParse("https://example.com", "https", "example.com", null, "", null, null); + testParse("https://example.com/", "https", "example.com", null, "/", null, null); + testParse("https://example.com/foo", "https", "example.com", null, "/foo", null, null); + testParse("https://example.com/foo/", "https", "example.com", null, "/foo/", null, null); + testParse("https://example.com:81/foo", "https", "example.com", "81", "/foo", null, null); + testParse("/foo", "", null, null, "/foo", null, null); + testParse("/foo/", "", null, null, "/foo/", null, null); + testParse("/foo/../bar", "", null, null, "/bar", null, null); + testParse("/foo/../bar/", "", null, null, "/bar/", null, null); + testParse("//other.info/foo/bar", "", "other.info", null, "/foo/bar", null, null); + testParse("//other.info/parent/../foo/bar", "", "other.info", null, "/foo/bar", null, null); + } + + private void testParse(String input, String scheme, @Nullable String host, @Nullable String port, String path, @Nullable String query, @Nullable String fragment) { + WhatWgUrlParser.UrlRecord result = WhatWgUrlParser.parse(input, EMPTY_URL_RECORD, null, null); + assertThat(result.scheme()).as("Invalid scheme").isEqualTo(scheme); + if (host != null) { + assertThat(result.host()).as("Host is null").isNotNull(); + assertThat(result.host().toString()).as("Invalid host").isEqualTo(host); + } + else { + assertThat(result.host()).as("Host is not null").isNull(); + } + if (port != null) { + assertThat(result.port()).as("Port is null").isNotNull(); + assertThat(result.port().toString()).as("Invalid port").isEqualTo(port); + } + else { + assertThat(result.port()).as("Port is not null").isNull(); + } + assertThat(result.hasOpaquePath()).as("Result has opaque path").isFalse(); + assertThat(result.path().toString()).as("Invalid path").isEqualTo(path); + assertThat(result.query()).as("Invalid query").isEqualTo(query); + assertThat(result.fragment()).as("Invalid fragment").isEqualTo(fragment); + } + + @Test + void parseOpaque() { + testParseOpaque("mailto:user@example.com?subject=foo", "user@example.com", "subject=foo"); + + } + + void testParseOpaque(String input, String path, @Nullable String query) { + WhatWgUrlParser.UrlRecord result = WhatWgUrlParser.parse("mailto:user@example.com?subject=foo", EMPTY_URL_RECORD, null, null); + + + assertThat(result.scheme()).as("Invalid scheme").isEqualTo("mailto"); + assertThat(result.hasOpaquePath()).as("Result has no opaque path").isTrue(); + assertThat(result.path().toString()).as("Invalid path").isEqualTo(path); + if (query != null) { + assertThat(result.query()).as("Query is null").isNotNull(); + assertThat(result.query()).as("Invalid query").isEqualTo(query); + } + else { + assertThat(result.query()).as("Query is not null").isNull(); + } + } +} diff --git a/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt b/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt index 990b35a828b9..dcc145259685 100644 --- a/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt @@ -19,10 +19,12 @@ package org.springframework.http.codec.json import kotlinx.serialization.Serializable import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.springframework.core.MethodParameter import org.springframework.core.Ordered import org.springframework.core.ResolvableType import org.springframework.core.codec.DecodingException import org.springframework.core.io.buffer.DataBuffer +import org.springframework.core.io.buffer.DataBufferUtils import org.springframework.core.testfixture.codec.AbstractDecoderTests import org.springframework.http.MediaType import reactor.core.publisher.Flux @@ -31,6 +33,7 @@ import reactor.test.StepVerifier.FirstStep import java.math.BigDecimal import java.nio.charset.Charset import java.nio.charset.StandardCharsets +import kotlin.reflect.jvm.javaMethod /** * Tests for the JSON decoding using kotlinx.serialization. @@ -150,6 +153,22 @@ class KotlinSerializationJsonDecoderTests : AbstractDecoderTests { return stringBuffer(value, StandardCharsets.UTF_8) } @@ -167,4 +186,6 @@ class KotlinSerializationJsonDecoderTests : AbstractDecoderTests) = map + } diff --git a/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonEncoderTests.kt b/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonEncoderTests.kt index 8e00528f8c0f..d5c92147929b 100644 --- a/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonEncoderTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonEncoderTests.kt @@ -19,18 +19,18 @@ package org.springframework.http.codec.json import kotlinx.serialization.Serializable import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.springframework.core.MethodParameter import org.springframework.core.Ordered import org.springframework.core.ResolvableType -import org.springframework.core.io.buffer.DataBuffer -import org.springframework.core.io.buffer.DataBufferUtils import org.springframework.core.testfixture.codec.AbstractEncoderTests import org.springframework.http.MediaType import org.springframework.http.codec.ServerSentEvent import reactor.core.publisher.Flux import reactor.core.publisher.Mono -import reactor.test.StepVerifier.FirstStep +import reactor.test.StepVerifier import java.math.BigDecimal import java.nio.charset.StandardCharsets +import kotlin.reflect.jvm.javaMethod /** * Tests for the JSON encoding using kotlinx.serialization. @@ -70,15 +70,32 @@ class KotlinSerializationJsonEncoderTests : AbstractEncoderTests DataBufferUtils.release(dataBuffer) }) + it.consumeNextWith(expectString("[{\"foo\":\"foo\",\"bar\":\"bar\"}")) + .consumeNextWith(expectString(",{\"foo\":\"foofoo\",\"bar\":\"barbar\"}")) + .consumeNextWith(expectString(",{\"foo\":\"foofoofoo\",\"bar\":\"barbarbar\"}")) + .consumeNextWith(expectString("]")) .verifyComplete() } } + @Test + fun encodeEmpty() { + testEncode(Flux.empty(), Pojo::class.java) { + it + .consumeNextWith(expectString("[")) + .consumeNextWith(expectString("]")) + .verifyComplete() + } + } + + @Test + fun encodeWithErrorAsFirstSignal() { + val message = "I'm a teapot" + val input = Flux.error(IllegalStateException(message)) + val output = encoder.encode(input, this.bufferFactory, ResolvableType.forClass(Pojo::class.java), null, null) + StepVerifier.create(output).expectErrorMessage(message).verify() + } + @Test fun encodeStream() { val input = Flux.just( @@ -103,9 +120,18 @@ class KotlinSerializationJsonEncoderTests : AbstractEncoderTests DataBufferUtils.release(dataBuffer) }) - .verifyComplete() + it.consumeNextWith(expectString("{\"foo\":\"foo\",\"bar\":\"bar\"}")) + .verifyComplete() + } + } + + @Test + fun encodeMonoWithNullableWithNull() { + val input = Mono.just(mapOf("value" to null)) + val methodParameter = MethodParameter.forExecutable(::handleMapWithNullable::javaMethod.get()!!, -1) + testEncode(input, ResolvableType.forMethodParameter(methodParameter), null, null) { + it.consumeNextWith(expectString("{\"value\":null}")) + .verifyComplete() } } @@ -119,8 +145,24 @@ class KotlinSerializationJsonEncoderTests : AbstractEncoderTests) = map + + val value: Int + get() = 42 + } diff --git a/spring-web/src/test/kotlin/org/springframework/http/converter/cbor/KotlinSerializationCborHttpMessageConverterTests.kt b/spring-web/src/test/kotlin/org/springframework/http/converter/cbor/KotlinSerializationCborHttpMessageConverterTests.kt index d98e2e2d72f9..52e55ab22d5f 100644 --- a/spring-web/src/test/kotlin/org/springframework/http/converter/cbor/KotlinSerializationCborHttpMessageConverterTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/http/converter/cbor/KotlinSerializationCborHttpMessageConverterTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author 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,7 +17,6 @@ package org.springframework.http.converter.cbor import java.lang.reflect.ParameterizedType -import java.lang.reflect.Type import java.nio.charset.StandardCharsets import kotlin.reflect.javaType @@ -31,6 +30,7 @@ import org.assertj.core.api.Assertions.assertThatExceptionOfType import org.junit.jupiter.api.Test import org.springframework.core.Ordered +import org.springframework.core.ResolvableType import org.springframework.http.MediaType import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.web.testfixture.http.MockHttpInputMessage @@ -67,18 +67,18 @@ class KotlinSerializationCborHttpMessageConverterTests { assertThat(converter.canRead(NotSerializableBean::class.java, MediaType.APPLICATION_CBOR)).isFalse() assertThat(converter.canRead(Map::class.java, MediaType.APPLICATION_CBOR)).isFalse() - assertThat(converter.canRead(typeTokenOf>(), Map::class.java, MediaType.APPLICATION_CBOR)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_CBOR)).isTrue() assertThat(converter.canRead(List::class.java, MediaType.APPLICATION_CBOR)).isFalse() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, MediaType.APPLICATION_CBOR)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_CBOR)).isTrue() assertThat(converter.canRead(Set::class.java, MediaType.APPLICATION_CBOR)).isFalse() - assertThat(converter.canRead(typeTokenOf>(), Set::class.java, MediaType.APPLICATION_CBOR)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_CBOR)).isTrue() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, MediaType.APPLICATION_CBOR)).isTrue() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, MediaType.APPLICATION_CBOR)).isTrue() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, MediaType.APPLICATION_JSON)).isFalse() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_CBOR)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_CBOR)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canRead(typeTokenOf(), Ordered::class.java, MediaType.APPLICATION_CBOR)).isFalse() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, MediaType.APPLICATION_CBOR)).isFalse() + assertThat(converter.canRead(resolvableTypeOf(), MediaType.APPLICATION_CBOR)).isFalse() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_CBOR)).isFalse() } @Test @@ -89,17 +89,17 @@ class KotlinSerializationCborHttpMessageConverterTests { assertThat(converter.canWrite(NotSerializableBean::class.java, MediaType.APPLICATION_CBOR)).isFalse() assertThat(converter.canWrite(Map::class.java, MediaType.APPLICATION_CBOR)).isFalse() - assertThat(converter.canWrite(typeTokenOf>(), Map::class.java, MediaType.APPLICATION_CBOR)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), Map::class.java, MediaType.APPLICATION_CBOR)).isTrue() assertThat(converter.canWrite(List::class.java, MediaType.APPLICATION_CBOR)).isFalse() - assertThat(converter.canWrite(typeTokenOf>(), List::class.java, MediaType.APPLICATION_CBOR)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), List::class.java, MediaType.APPLICATION_CBOR)).isTrue() assertThat(converter.canWrite(Set::class.java, MediaType.APPLICATION_CBOR)).isFalse() - assertThat(converter.canWrite(typeTokenOf>(), Set::class.java, MediaType.APPLICATION_CBOR)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), Set::class.java, MediaType.APPLICATION_CBOR)).isTrue() - assertThat(converter.canWrite(typeTokenOf>(), List::class.java, MediaType.APPLICATION_CBOR)).isTrue() - assertThat(converter.canWrite(typeTokenOf>(), List::class.java, MediaType.APPLICATION_CBOR)).isTrue() - assertThat(converter.canWrite(typeTokenOf>(), List::class.java, MediaType.APPLICATION_JSON)).isFalse() + assertThat(converter.canWrite(resolvableTypeOf>(), List::class.java, MediaType.APPLICATION_CBOR)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), List::class.java, MediaType.APPLICATION_CBOR)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), List::class.java, MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canWrite(typeTokenOf(), Ordered::class.java, MediaType.APPLICATION_CBOR)).isFalse() + assertThat(converter.canWrite(resolvableTypeOf(), Ordered::class.java, MediaType.APPLICATION_CBOR)).isFalse() } @Test @@ -139,7 +139,7 @@ class KotlinSerializationCborHttpMessageConverterTests { fun readGenericCollection() { val inputMessage = MockHttpInputMessage(serializableBeanArrayBody) inputMessage.headers.contentType = MediaType.APPLICATION_CBOR - val result = converter.read(typeOf>().javaType, null, inputMessage) + val result = converter.read(ResolvableType.forType(typeOf>().javaType), inputMessage, null) as List assertThat(result).hasSize(1) @@ -200,7 +200,7 @@ class KotlinSerializationCborHttpMessageConverterTests { fun writeGenericCollection() { val outputMessage = MockHttpOutputMessage() - this.converter.write(listOf(serializableBean), typeOf>().javaType, null, outputMessage) + this.converter.write(listOf(serializableBean), ResolvableType.forType(typeOf>().javaType), null, outputMessage, null) assertThat(outputMessage.headers).containsEntry("Content-Type", listOf("application/cbor")) assertThat(outputMessage.bodyAsBytes.isNotEmpty()).isTrue() @@ -222,10 +222,10 @@ class KotlinSerializationCborHttpMessageConverterTests { open class TypeBase - inline fun typeTokenOf(): Type { + private inline fun resolvableTypeOf(): ResolvableType { val base = object : TypeBase() {} val superType = base::class.java.genericSuperclass!! - return (superType as ParameterizedType).actualTypeArguments.first()!! + return ResolvableType.forType((superType as ParameterizedType).actualTypeArguments.first()!!) } } diff --git a/spring-web/src/test/kotlin/org/springframework/http/converter/json/KotlinSerializationJsonHttpMessageConverterTests.kt b/spring-web/src/test/kotlin/org/springframework/http/converter/json/KotlinSerializationJsonHttpMessageConverterTests.kt index b4bd9d471122..6bc08fb96f25 100644 --- a/spring-web/src/test/kotlin/org/springframework/http/converter/json/KotlinSerializationJsonHttpMessageConverterTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/http/converter/json/KotlinSerializationJsonHttpMessageConverterTests.kt @@ -16,17 +16,11 @@ package org.springframework.http.converter.json -import java.lang.reflect.ParameterizedType -import java.lang.reflect.Type -import java.nio.charset.StandardCharsets - import kotlinx.serialization.Serializable import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatExceptionOfType import org.junit.jupiter.api.Test -import kotlin.reflect.javaType -import kotlin.reflect.typeOf - +import org.springframework.core.MethodParameter import org.springframework.core.Ordered import org.springframework.core.ResolvableType import org.springframework.http.MediaType @@ -34,7 +28,12 @@ import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.http.customJson import org.springframework.web.testfixture.http.MockHttpInputMessage import org.springframework.web.testfixture.http.MockHttpOutputMessage +import java.lang.reflect.ParameterizedType import java.math.BigDecimal +import java.nio.charset.StandardCharsets +import kotlin.reflect.javaType +import kotlin.reflect.jvm.javaMethod +import kotlin.reflect.typeOf /** * Tests for the JSON conversion using kotlinx.serialization. @@ -55,22 +54,22 @@ class KotlinSerializationJsonHttpMessageConverterTests { assertThat(converter.canRead(NotSerializableBean::class.java, MediaType.APPLICATION_JSON)).isFalse() assertThat(converter.canRead(Map::class.java, MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canRead(typeTokenOf>(), Map::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_JSON)).isTrue() assertThat(converter.canRead(List::class.java, MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_JSON)).isTrue() assertThat(converter.canRead(Set::class.java, MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canRead(typeTokenOf>(), Set::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_JSON)).isTrue() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, MediaType.APPLICATION_JSON)).isTrue() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, MediaType.APPLICATION_JSON)).isTrue() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, MediaType.APPLICATION_PDF)).isFalse() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_PDF)).isFalse() - assertThat(converter.canRead(typeTokenOf(), Ordered::class.java, MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, MediaType.APPLICATION_JSON)).isFalse() + assertThat(converter.canRead(resolvableTypeOf(), MediaType.APPLICATION_JSON)).isFalse() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canRead(ResolvableType.NONE.type, null, MediaType.APPLICATION_JSON)).isFalse() + assertThat(converter.canRead(ResolvableType.forType(ResolvableType.NONE.type), MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canRead(BigDecimal::class.java, null, MediaType.APPLICATION_JSON)).isFalse() + assertThat(converter.canRead(ResolvableType.forType(BigDecimal::class.java), MediaType.APPLICATION_JSON)).isFalse() } @Test @@ -81,21 +80,21 @@ class KotlinSerializationJsonHttpMessageConverterTests { assertThat(converter.canWrite(NotSerializableBean::class.java, MediaType.APPLICATION_JSON)).isFalse() assertThat(converter.canWrite(Map::class.java, MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canWrite(typeTokenOf>(), Map::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), Map::class.java, MediaType.APPLICATION_JSON)).isTrue() assertThat(converter.canWrite(List::class.java, MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canWrite(typeTokenOf>(), List::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), List::class.java, MediaType.APPLICATION_JSON)).isTrue() assertThat(converter.canWrite(Set::class.java, MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canWrite(typeTokenOf>(), Set::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), Set::class.java, MediaType.APPLICATION_JSON)).isTrue() - assertThat(converter.canWrite(typeTokenOf>(), List::class.java, MediaType.APPLICATION_JSON)).isTrue() - assertThat(converter.canWrite(typeTokenOf>(), List::class.java, MediaType.APPLICATION_JSON)).isTrue() - assertThat(converter.canWrite(typeTokenOf>(), List::class.java, MediaType.APPLICATION_PDF)).isFalse() + assertThat(converter.canWrite(resolvableTypeOf>(), List::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), List::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), List::class.java, MediaType.APPLICATION_PDF)).isFalse() - assertThat(converter.canWrite(typeTokenOf(), Ordered::class.java, MediaType.APPLICATION_JSON)).isFalse() + assertThat(converter.canWrite(resolvableTypeOf(), Ordered::class.java, MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canWrite(ResolvableType.NONE.type, SerializableBean::class.java, MediaType.APPLICATION_JSON)).isFalse() + assertThat(converter.canWrite(ResolvableType.NONE, SerializableBean::class.java, MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canWrite(BigDecimal::class.java, BigDecimal::class.java, MediaType.APPLICATION_JSON)).isFalse() + assertThat(converter.canWrite(ResolvableType.forType(BigDecimal::class.java), BigDecimal::class.java, MediaType.APPLICATION_JSON)).isFalse() } @Test @@ -198,7 +197,7 @@ class KotlinSerializationJsonHttpMessageConverterTests { """.trimIndent() val inputMessage = MockHttpInputMessage(body.toByteArray(charset("UTF-8"))) inputMessage.headers.contentType = MediaType.APPLICATION_JSON - val result = converter.read(typeOf>().javaType, null, inputMessage) + val result = converter.read(ResolvableType.forType(typeOf>().javaType), inputMessage, null) as List assertThat(result).hasSize(1) @@ -234,6 +233,19 @@ class KotlinSerializationJsonHttpMessageConverterTests { } } + @Test + @Suppress("UNCHECKED_CAST") + fun readNullableWithNull() { + val body = """{"value":null}""" + + val inputMessage = MockHttpInputMessage(body.toByteArray(StandardCharsets.UTF_8)) + inputMessage.headers.contentType = MediaType.APPLICATION_JSON + val methodParameter = MethodParameter.forExecutable(::handleMapWithNullable::javaMethod.get()!!, 0) + val result = converter.read(ResolvableType.forMethodParameter(methodParameter), inputMessage, null) as Map + + assertThat(result).containsExactlyEntriesOf(mapOf("value" to null)) + } + @Test fun writeObject() { val outputMessage = MockHttpOutputMessage() @@ -297,8 +309,8 @@ class KotlinSerializationJsonHttpMessageConverterTests { [{"bytes":[1,2],"array":["Foo","Bar"],"number":42,"string":"Foo","bool":true,"fraction":42.0}] """.trimIndent() - this.converter.write(arrayListOf(serializableBean), typeOf>().javaType, null, - outputMessage) + this.converter.write(arrayListOf(serializableBean), ResolvableType.forType(typeOf>().javaType), null, + outputMessage, null) val result = outputMessage.getBodyAsString(StandardCharsets.UTF_8) @@ -356,6 +368,36 @@ class KotlinSerializationJsonHttpMessageConverterTests { assertThat(result).isEqualTo("1.0") } + @Test + @ExperimentalStdlibApi + fun writeNullableWithNull() { + val outputMessage = MockHttpOutputMessage() + val serializableBean = mapOf("value" to null) + val expectedJson = """{"value":null}""" + val methodParameter = MethodParameter.forExecutable(::handleMapWithNullable::javaMethod.get()!!, -1) + + this.converter.write(serializableBean, ResolvableType.forMethodParameter(methodParameter), null, + outputMessage, null) + + val result = outputMessage.getBodyAsString(StandardCharsets.UTF_8) + + assertThat(outputMessage.headers).containsEntry("Content-Type", listOf("application/json")) + assertThat(result).isEqualTo(expectedJson) + } + + @Test + fun writeProperty() { + val outputMessage = MockHttpOutputMessage() + val method = this::class.java.getDeclaredMethod("getValue") + val methodParameter = MethodParameter.forExecutable(method, -1) + + this.converter.write(value, ResolvableType.forMethodParameter(methodParameter), null, outputMessage, null) + val result = outputMessage.getBodyAsString(StandardCharsets.UTF_8) + + assertThat(outputMessage.headers).containsEntry("Content-Type", listOf("application/json")) + assertThat(result).isEqualTo("42") + } + @Serializable @Suppress("ArrayInDataClass") @@ -373,10 +415,15 @@ class KotlinSerializationJsonHttpMessageConverterTests { open class TypeBase - inline fun typeTokenOf(): Type { + private inline fun resolvableTypeOf(): ResolvableType { val base = object : TypeBase() {} val superType = base::class.java.genericSuperclass!! - return (superType as ParameterizedType).actualTypeArguments.first()!! + return ResolvableType.forType((superType as ParameterizedType).actualTypeArguments.first()!!) } + fun handleMapWithNullable(map: Map) = map + + val value: Int + get() = 42 + } diff --git a/spring-web/src/test/kotlin/org/springframework/http/converter/protobuf/KotlinSerializationProtobufHttpMessageConverterTests.kt b/spring-web/src/test/kotlin/org/springframework/http/converter/protobuf/KotlinSerializationProtobufHttpMessageConverterTests.kt index d74d530734e6..b79efc9929e6 100644 --- a/spring-web/src/test/kotlin/org/springframework/http/converter/protobuf/KotlinSerializationProtobufHttpMessageConverterTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/http/converter/protobuf/KotlinSerializationProtobufHttpMessageConverterTests.kt @@ -24,12 +24,12 @@ import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatExceptionOfType import org.junit.jupiter.api.Test import org.springframework.core.Ordered +import org.springframework.core.ResolvableType import org.springframework.http.MediaType import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.web.testfixture.http.MockHttpInputMessage import org.springframework.web.testfixture.http.MockHttpOutputMessage import java.lang.reflect.ParameterizedType -import java.lang.reflect.Type import java.nio.charset.StandardCharsets import kotlin.reflect.javaType import kotlin.reflect.typeOf @@ -69,20 +69,20 @@ class KotlinSerializationProtobufHttpMessageConverterTests { assertThat(converter.canRead(NotSerializableBean::class.java, mimeType)).isFalse() assertThat(converter.canRead(Map::class.java, mimeType)).isFalse() - assertThat(converter.canRead(typeTokenOf>(), Map::class.java, mimeType)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), mimeType)).isTrue() assertThat(converter.canRead(List::class.java, mimeType)).isFalse() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, mimeType)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), mimeType)).isTrue() assertThat(converter.canRead(Set::class.java, mimeType)).isFalse() - assertThat(converter.canRead(typeTokenOf>(), Set::class.java, mimeType)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), mimeType)).isTrue() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, mimeType)).isTrue() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, mimeType)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(), mimeType)).isTrue() + assertThat(converter.canRead(resolvableTypeOf>(),mimeType)).isTrue() - assertThat(converter.canRead(typeTokenOf(), Ordered::class.java, mimeType)).isFalse() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, mimeType)).isFalse() + assertThat(converter.canRead(resolvableTypeOf(), mimeType)).isFalse() + assertThat(converter.canRead(resolvableTypeOf>(), mimeType)).isFalse() } assertThat(converter.canRead(SerializableBean::class.java, MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canRead(typeTokenOf>(), List::class.java, MediaType.APPLICATION_JSON)).isFalse() + assertThat(converter.canRead(resolvableTypeOf>(), MediaType.APPLICATION_JSON)).isFalse() } @Test @@ -93,20 +93,20 @@ class KotlinSerializationProtobufHttpMessageConverterTests { assertThat(converter.canWrite(NotSerializableBean::class.java, mimeType)).isFalse() assertThat(converter.canWrite(Map::class.java, mimeType)).isFalse() - assertThat(converter.canWrite(typeTokenOf>(), Map::class.java, mimeType)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), Map::class.java, mimeType)).isTrue() assertThat(converter.canWrite(List::class.java, mimeType)).isFalse() - assertThat(converter.canWrite(typeTokenOf>(), List::class.java, mimeType)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), List::class.java, mimeType)).isTrue() assertThat(converter.canWrite(Set::class.java, mimeType)).isFalse() - assertThat(converter.canWrite(typeTokenOf>(), Set::class.java, mimeType)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), Set::class.java, mimeType)).isTrue() - assertThat(converter.canWrite(typeTokenOf>(), List::class.java, mimeType)).isTrue() - assertThat(converter.canWrite(typeTokenOf>(), List::class.java, mimeType)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), List::class.java, mimeType)).isTrue() + assertThat(converter.canWrite(resolvableTypeOf>(), List::class.java, mimeType)).isTrue() - assertThat(converter.canWrite(typeTokenOf(), Ordered::class.java, mimeType)).isFalse() + assertThat(converter.canWrite(resolvableTypeOf(), Ordered::class.java, mimeType)).isFalse() } assertThat(converter.canWrite(SerializableBean::class.java, MediaType.APPLICATION_JSON)).isFalse() - assertThat(converter.canWrite(typeTokenOf>(), List::class.java, MediaType.APPLICATION_JSON)).isFalse() + assertThat(converter.canWrite(resolvableTypeOf>(), List::class.java, MediaType.APPLICATION_JSON)).isFalse() } @Test @@ -152,8 +152,8 @@ class KotlinSerializationProtobufHttpMessageConverterTests { for (mimeType in mediaTypes) { val inputMessage = MockHttpInputMessage(serializableBeanArrayBody) inputMessage.headers.contentType = mimeType - val result = converter.read(typeOf>().javaType, null, inputMessage) - as List + val result = converter.read(ResolvableType.forType(typeOf>().javaType), inputMessage, + null) as List assertThat(result).hasSize(1) assertThat(result[0].bytes).containsExactly(*serializableBean.bytes) @@ -216,7 +216,7 @@ class KotlinSerializationProtobufHttpMessageConverterTests { fun writeGenericCollection() { val outputMessage = MockHttpOutputMessage() - this.converter.write(listOf(serializableBean), typeOf>().javaType, null, outputMessage) + this.converter.write(listOf(serializableBean), ResolvableType.forType(typeOf>().javaType), null, outputMessage, null) assertThat(outputMessage.headers).containsEntry("Content-Type", listOf("application/x-protobuf")) assertThat(outputMessage.bodyAsBytes.isNotEmpty()).isTrue() @@ -238,10 +238,10 @@ class KotlinSerializationProtobufHttpMessageConverterTests { open class TypeBase - inline fun typeTokenOf(): Type { + private inline fun resolvableTypeOf(): ResolvableType { val base = object : TypeBase() {} val superType = base::class.java.genericSuperclass!! - return (superType as ParameterizedType).actualTypeArguments.first()!! + return ResolvableType.forType((superType as ParameterizedType).actualTypeArguments.first()!!) } } diff --git a/spring-web/src/test/kotlin/org/springframework/web/client/RestClientExtensionsTests.kt b/spring-web/src/test/kotlin/org/springframework/web/client/RestClientExtensionsTests.kt index 8b68868f3281..6e915901664b 100644 --- a/spring-web/src/test/kotlin/org/springframework/web/client/RestClientExtensionsTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/web/client/RestClientExtensionsTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +16,11 @@ package org.springframework.web.client +import io.mockk.every import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.springframework.core.ParameterizedTypeReference /** @@ -45,6 +47,18 @@ class RestClientExtensionsTests { verify { responseSpec.body(object : ParameterizedTypeReference>() {}) } } + @Test + fun `ResponseSpec#requiredBody with reified type parameters`() { + responseSpec.requiredBody>() + verify { responseSpec.body(object : ParameterizedTypeReference>() {}) } + } + + @Test + fun `ResponseSpec#requiredBody with null response throws NoSuchElementException`() { + every { responseSpec.body(any>()) } returns null + assertThrows { responseSpec.requiredBody() } + } + @Test fun `ResponseSpec#toEntity with reified type parameters`() { responseSpec.toEntity>() diff --git a/spring-web/src/test/kotlin/org/springframework/web/client/support/KotlinRestTemplateHttpServiceProxyTests.kt b/spring-web/src/test/kotlin/org/springframework/web/client/support/KotlinRestTemplateHttpServiceProxyTests.kt index b506f7108158..d427b89565ac 100644 --- a/spring-web/src/test/kotlin/org/springframework/web/client/support/KotlinRestTemplateHttpServiceProxyTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/web/client/support/KotlinRestTemplateHttpServiceProxyTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -136,8 +136,7 @@ class KotlinRestTemplateHttpServiceProxyTests { testService.postForm(map) val request = server.takeRequest() - assertThat(request.headers["Content-Type"]) - .isEqualTo("application/x-www-form-urlencoded;charset=UTF-8") + assertThat(request.headers["Content-Type"]).isEqualTo("application/x-www-form-urlencoded") assertThat(request.body.readUtf8()).isEqualTo("param1=value+1¶m2=value+2") } diff --git a/spring-web/src/test/kotlin/org/springframework/web/method/support/InvocableHandlerMethodKotlinTests.kt b/spring-web/src/test/kotlin/org/springframework/web/method/support/InvocableHandlerMethodKotlinTests.kt index 82cac5450d70..c1f63e8b30cc 100644 --- a/spring-web/src/test/kotlin/org/springframework/web/method/support/InvocableHandlerMethodKotlinTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/web/method/support/InvocableHandlerMethodKotlinTests.kt @@ -16,19 +16,28 @@ package org.springframework.web.method.support +import kotlinx.coroutines.delay import org.assertj.core.api.Assertions import org.junit.jupiter.api.Test +import org.springframework.core.MethodParameter +import org.springframework.util.ReflectionUtils +import org.springframework.web.bind.support.WebDataBinderFactory import org.springframework.web.context.request.NativeWebRequest import org.springframework.web.context.request.ServletWebRequest -import org.springframework.web.testfixture.method.ResolvableMethod import org.springframework.web.testfixture.servlet.MockHttpServletRequest import org.springframework.web.testfixture.servlet.MockHttpServletResponse +import reactor.core.publisher.Mono +import reactor.test.StepVerifier +import java.lang.reflect.Method +import kotlin.reflect.jvm.javaGetter +import kotlin.reflect.jvm.javaMethod /** * Kotlin unit tests for [InvocableHandlerMethod]. * * @author Sebastien Deleuze */ +@Suppress("UNCHECKED_CAST") class InvocableHandlerMethodKotlinTests { private val request: NativeWebRequest = ServletWebRequest(MockHttpServletRequest(), MockHttpServletResponse()) @@ -38,7 +47,7 @@ class InvocableHandlerMethodKotlinTests { @Test fun intDefaultValue() { composite.addResolver(StubArgumentResolver(Int::class.java, null)) - val value = getInvocable(Handler::class.java, Int::class.java).invokeForRequest(request, null) + val value = getInvocable(Handler::intDefaultValue.javaMethod!!).invokeForRequest(request, null) Assertions.assertThat(getStubResolver(0).resolvedParameters).hasSize(1) Assertions.assertThat(value).isEqualTo("20") @@ -47,7 +56,7 @@ class InvocableHandlerMethodKotlinTests { @Test fun booleanDefaultValue() { composite.addResolver(StubArgumentResolver(Boolean::class.java, null)) - val value = getInvocable(Handler::class.java, Boolean::class.java).invokeForRequest(request, null) + val value = getInvocable(Handler::booleanDefaultValue.javaMethod!!).invokeForRequest(request, null) Assertions.assertThat(getStubResolver(0).resolvedParameters).hasSize(1) Assertions.assertThat(value).isEqualTo("true") @@ -56,7 +65,7 @@ class InvocableHandlerMethodKotlinTests { @Test fun nullableIntDefaultValue() { composite.addResolver(StubArgumentResolver(Int::class.javaObjectType, null)) - val value = getInvocable(Handler::class.java, Int::class.javaObjectType).invokeForRequest(request, null) + val value = getInvocable(Handler::nullableIntDefaultValue.javaMethod!!).invokeForRequest(request, null) Assertions.assertThat(getStubResolver(0).resolvedParameters).hasSize(1) Assertions.assertThat(value).isEqualTo("20") @@ -65,7 +74,7 @@ class InvocableHandlerMethodKotlinTests { @Test fun nullableBooleanDefaultValue() { composite.addResolver(StubArgumentResolver(Boolean::class.javaObjectType, null)) - val value = getInvocable(Handler::class.java, Boolean::class.javaObjectType).invokeForRequest(request, null) + val value = getInvocable(Handler::nullableBooleanDefaultValue.javaMethod!!).invokeForRequest(request, null) Assertions.assertThat(getStubResolver(0).resolvedParameters).hasSize(1) Assertions.assertThat(value).isEqualTo("true") @@ -73,62 +82,137 @@ class InvocableHandlerMethodKotlinTests { @Test fun unitReturnValue() { - val value = getInvocable(Handler::class.java).invokeForRequest(request, null) + val value = getInvocable(Handler::unit.javaMethod!!).invokeForRequest(request, null) Assertions.assertThat(value).isNull() } @Test fun nullReturnValue() { composite.addResolver(StubArgumentResolver(String::class.java, null)) - val value = getInvocable(Handler::class.java, String::class.java).invokeForRequest(request, null) + val value = getInvocable(Handler::nullable.javaMethod!!).invokeForRequest(request, null) Assertions.assertThat(value).isNull() } + @Test + fun private() { + composite.addResolver(StubArgumentResolver(Float::class.java, 1.2f)) + val value = getInvocable(ReflectionUtils.findMethod(Handler::class.java, "private", Float::class.java)!!).invokeForRequest(request, null) + + Assertions.assertThat(getStubResolver(0).resolvedParameters).hasSize(1) + Assertions.assertThat(value).isEqualTo("1.2") + } + @Test fun valueClass() { composite.addResolver(StubArgumentResolver(Long::class.java, 1L)) - val value = getInvocable(ValueClassHandler::class.java, Long::class.java).invokeForRequest(request, null) + val value = getInvocable(ValueClassHandler::longValueClass.javaMethod!!).invokeForRequest(request, null) Assertions.assertThat(value).isEqualTo(1L) } + @Test + fun valueClassReturnValue() { + val value = getInvocable(ValueClassHandler::valueClassReturnValue.javaMethod!!).invokeForRequest(request, null) + Assertions.assertThat(value).isEqualTo("foo") + } + + @Test + fun resultOfUnitReturnValue() { + val value = getInvocable(ValueClassHandler::resultOfUnitReturnValue.javaMethod!!).invokeForRequest(request, null) + Assertions.assertThat(value).isNull() + } + @Test fun valueClassDefaultValue() { composite.addResolver(StubArgumentResolver(Double::class.java)) - val value = getInvocable(ValueClassHandler::class.java, Double::class.java).invokeForRequest(request, null) + val value = getInvocable(ValueClassHandler::doubleValueClass.javaMethod!!).invokeForRequest(request, null) Assertions.assertThat(value).isEqualTo(3.1) } @Test fun valueClassWithInit() { composite.addResolver(StubArgumentResolver(String::class.java, "")) - val invocable = getInvocable(ValueClassHandler::class.java, String::class.java) + val invocable = getInvocable(ValueClassHandler::valueClassWithInit.javaMethod!!) Assertions.assertThatIllegalArgumentException().isThrownBy { invocable.invokeForRequest(request, null) } } @Test fun valueClassWithNullable() { composite.addResolver(StubArgumentResolver(LongValueClass::class.java, null)) - val value = getInvocable(ValueClassHandler::class.java, LongValueClass::class.java).invokeForRequest(request, null) + val value = getInvocable(ValueClassHandler::valueClassWithNullable.javaMethod!!).invokeForRequest(request, null) Assertions.assertThat(value).isNull() } @Test fun valueClassWithPrivateConstructor() { composite.addResolver(StubArgumentResolver(Char::class.java, 'a')) - val value = getInvocable(ValueClassHandler::class.java, Char::class.java).invokeForRequest(request, null) + val value = getInvocable(ValueClassHandler::valueClassWithPrivateConstructor.javaMethod!!).invokeForRequest(request, null) Assertions.assertThat(value).isEqualTo('a') } + @Test + fun suspendingValueClass() { + composite.addResolver(ContinuationHandlerMethodArgumentResolver()) + composite.addResolver(StubArgumentResolver(Long::class.java, 1L)) + val value = getInvocable(SuspendingValueClassHandler::longValueClass.javaMethod!!).invokeForRequest(request, null) + StepVerifier.create(value as Mono).expectNext(1L).verifyComplete() + } + + @Test + fun suspendingValueClassReturnValue() { + composite.addResolver(ContinuationHandlerMethodArgumentResolver()) + val value = getInvocable(SuspendingValueClassHandler::valueClassReturnValue.javaMethod!!).invokeForRequest(request, null) + StepVerifier.create(value as Mono).expectNext("foo").verifyComplete() + } + + @Test + fun suspendingResultOfUnitReturnValue() { + composite.addResolver(ContinuationHandlerMethodArgumentResolver()) + val value = getInvocable(SuspendingValueClassHandler::resultOfUnitReturnValue.javaMethod!!).invokeForRequest(request, null) + StepVerifier.create(value as Mono).verifyComplete() + } + + @Test + fun suspendingValueClassDefaultValue() { + composite.addResolver(ContinuationHandlerMethodArgumentResolver()) + composite.addResolver(StubArgumentResolver(Double::class.java)) + val value = getInvocable(SuspendingValueClassHandler::doubleValueClass.javaMethod!!).invokeForRequest(request, null) + StepVerifier.create(value as Mono).expectNext(3.1).verifyComplete() + } + + @Test + fun suspendingValueClassWithInit() { + composite.addResolver(ContinuationHandlerMethodArgumentResolver()) + composite.addResolver(StubArgumentResolver(String::class.java, "")) + val value = getInvocable(SuspendingValueClassHandler::valueClassWithInit.javaMethod!!).invokeForRequest(request, null) + StepVerifier.create(value as Mono).verifyError(IllegalArgumentException::class.java) + } + + @Test + fun suspendingValueClassWithNullable() { + composite.addResolver(ContinuationHandlerMethodArgumentResolver()) + composite.addResolver(StubArgumentResolver(LongValueClass::class.java, null)) + val value = getInvocable(SuspendingValueClassHandler::valueClassWithNullable.javaMethod!!).invokeForRequest(request, null) + StepVerifier.create(value as Mono).verifyComplete() + } + + @Test + fun suspendingValueClassWithPrivateConstructor() { + composite.addResolver(ContinuationHandlerMethodArgumentResolver()) + composite.addResolver(StubArgumentResolver(Char::class.java, 'a')) + val value = getInvocable(SuspendingValueClassHandler::valueClassWithPrivateConstructor.javaMethod!!).invokeForRequest(request, null) + StepVerifier.create(value as Mono).expectNext('a').verifyComplete() + } + @Test fun propertyAccessor() { - val value = getInvocable(PropertyAccessorHandler::class.java).invokeForRequest(request, null) + val value = getInvocable(PropertyAccessorHandler::prop.javaGetter!!).invokeForRequest(request, null) Assertions.assertThat(value).isEqualTo("foo") } @Test fun extension() { composite.addResolver(StubArgumentResolver(CustomException::class.java, CustomException("foo"))) - val value = getInvocable(ExtensionHandler::class.java, CustomException::class.java).invokeForRequest(request, null) + val value = getInvocable(ReflectionUtils.findMethod(ExtensionHandler::class.java, "handle", CustomException::class.java)!!).invokeForRequest(request, null) Assertions.assertThat(value).isEqualTo("foo") } @@ -136,7 +220,7 @@ class InvocableHandlerMethodKotlinTests { fun extensionWithParameter() { composite.addResolver(StubArgumentResolver(CustomException::class.java, CustomException("foo"))) composite.addResolver(StubArgumentResolver(Int::class.java, 20)) - val value = getInvocable(ExtensionHandler::class.java, CustomException::class.java, Int::class.java) + val value = getInvocable(ReflectionUtils.findMethod(ExtensionHandler::class.java, "handleWithParameter", CustomException::class.java, Int::class.java)!!) .invokeForRequest(request, null) Assertions.assertThat(value).isEqualTo("foo-20") } @@ -145,12 +229,11 @@ class InvocableHandlerMethodKotlinTests { fun genericParameter() { val horse = Animal("horse") composite.addResolver(StubArgumentResolver(Animal::class.java, horse)) - val value = getInvocable(AnimalHandler::class.java, Named::class.java).invokeForRequest(request, null) + val value = getInvocable(AnimalHandler::handle.javaMethod!!, AnimalHandler::class.java).invokeForRequest(request, null) Assertions.assertThat(value).isEqualTo(horse.name) } - private fun getInvocable(clazz: Class<*>, vararg argTypes: Class<*>): InvocableHandlerMethod { - val method = ResolvableMethod.on(clazz).argTypes(*argTypes).resolveMethod() + private fun getInvocable(method: Method, clazz: Class<*> = method.declaringClass): InvocableHandlerMethod { val handlerMethod = InvocableHandlerMethod(clazz.constructors.first().newInstance(), method) handlerMethod.setHandlerMethodArgumentResolvers(composite) return handlerMethod @@ -182,24 +265,64 @@ class InvocableHandlerMethodKotlinTests { return null } + private fun private(value: Float) = value.toString() + } private class ValueClassHandler { - fun valueClass(limit: LongValueClass) = - limit.value + fun valueClassReturnValue() = StringValueClass("foo") + + fun resultOfUnitReturnValue() = Result.success(Unit) + + fun longValueClass(limit: LongValueClass) = limit.value - fun valueClass(limit: DoubleValueClass = DoubleValueClass(3.1)) = - limit.value + fun doubleValueClass(limit: DoubleValueClass = DoubleValueClass(3.1)) = limit.value - fun valueClassWithInit(valueClass: ValueClassWithInit) = - valueClass + fun valueClassWithInit(valueClass: ValueClassWithInit) = valueClass - fun valueClassWithNullable(limit: LongValueClass?) = - limit?.value + fun valueClassWithNullable(limit: LongValueClass?) = limit?.value - fun valueClassWithPrivateConstructor(limit: ValueClassWithPrivateConstructor) = - limit.value + fun valueClassWithPrivateConstructor(limit: ValueClassWithPrivateConstructor) = limit.value + } + + private class SuspendingValueClassHandler { + + suspend fun valueClassReturnValue(): StringValueClass { + delay(1) + return StringValueClass("foo") + } + + suspend fun resultOfUnitReturnValue(): Result { + delay(1) + return Result.success(Unit) + } + + suspend fun longValueClass(limit: LongValueClass): Long { + delay(1) + return limit.value + } + + + suspend fun doubleValueClass(limit: DoubleValueClass = DoubleValueClass(3.1)): Double { + delay(1) + return limit.value + } + + suspend fun valueClassWithInit(valueClass: ValueClassWithInit): ValueClassWithInit { + delay(1) + return valueClass + } + + suspend fun valueClassWithNullable(limit: LongValueClass?): Long? { + delay(1) + return limit?.value + } + + suspend fun valueClassWithPrivateConstructor(limit: ValueClassWithPrivateConstructor): Char { + delay(1) + return limit.value + } } private class PropertyAccessorHandler { @@ -232,6 +355,9 @@ class InvocableHandlerMethodKotlinTests { data class Animal(override val name: String) : Named + @JvmInline + value class StringValueClass(val value: String) + @JvmInline value class LongValueClass(val value: Long) @@ -256,4 +382,19 @@ class InvocableHandlerMethodKotlinTests { class CustomException(message: String) : Throwable(message) + // Avoid adding a spring-webmvc dependency + class ContinuationHandlerMethodArgumentResolver : HandlerMethodArgumentResolver { + + override fun supportsParameter(parameter: MethodParameter) = + "kotlin.coroutines.Continuation" == parameter.getParameterType().getName() + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory? + ) = null + + } + } diff --git a/spring-web/src/test/kotlin/org/springframework/web/server/CoWebExceptionHandlerTests.kt b/spring-web/src/test/kotlin/org/springframework/web/server/CoWebExceptionHandlerTests.kt new file mode 100644 index 000000000000..a85f56debb74 --- /dev/null +++ b/spring-web/src/test/kotlin/org/springframework/web/server/CoWebExceptionHandlerTests.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.server + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest +import org.springframework.web.testfixture.server.MockServerWebExchange +import reactor.test.StepVerifier + +class CoWebExceptionHandlerTest { + + @Test + fun handle() { + val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("https://example.com")) + val ex = RuntimeException() + + val handler = MyCoWebExceptionHandler() + val result = handler.handle(exchange, ex) + + StepVerifier.create(result).verifyComplete() + + assertThat(exchange.attributes["foo"]).isEqualTo("bar") + } +} + +private class MyCoWebExceptionHandler : CoWebExceptionHandler() { + + override suspend fun coHandle(exchange: ServerWebExchange, ex: Throwable) { + exchange.attributes["foo"] = "bar" + } +} diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/MockClientHttpRequest.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/MockClientHttpRequest.java index 826b193712ec..1cf6a68e5963 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/MockClientHttpRequest.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/MockClientHttpRequest.java @@ -18,6 +18,8 @@ import java.io.IOException; import java.net.URI; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.springframework.http.HttpMethod; import org.springframework.http.client.ClientHttpRequest; @@ -46,6 +48,9 @@ public class MockClientHttpRequest extends MockHttpOutputMessage implements Clie private boolean executed = false; + @Nullable + Map attributes; + /** * Create a {@code MockClientHttpRequest} with {@link HttpMethod#GET GET} as @@ -115,6 +120,16 @@ public boolean isExecuted() { return this.executed; } + @Override + public Map getAttributes() { + Map attributes = this.attributes; + if (attributes == null) { + attributes = new ConcurrentHashMap<>(); + this.attributes = attributes; + } + return attributes; + } + /** * Set the {@link #isExecuted() executed} flag to {@code true} and return the * configured {@link #setResponse(ClientHttpResponse) response}. diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/reactive/MockClientHttpRequest.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/reactive/MockClientHttpRequest.java index daeefdf13f9f..b6c00b133416 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/reactive/MockClientHttpRequest.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/client/reactive/MockClientHttpRequest.java @@ -78,7 +78,7 @@ public MockClientHttpRequest(HttpMethod httpMethod, URI url) { * Configure a custom handler for writing the request body. * *

            The default write handler consumes and caches the request body so it - * may be accessed subsequently, e.g. in test assertions. Use this property + * may be accessed subsequently, for example, in test assertions. Use this property * when the request body is an infinite stream. * * @param writeHandler the write handler to use returning {@code Mono} @@ -121,6 +121,10 @@ protected void applyCookies() { .forEach(cookie -> getHeaders().add(HttpHeaders.COOKIE, cookie.toString())); } + @Override + protected void applyAttributes() { + } + @Override public Mono writeWith(Publisher body) { return doCommit(() -> Mono.defer(() -> this.writeHandler.apply(Flux.from(body)))); diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/AbstractHttpHandlerIntegrationTests.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/AbstractHttpHandlerIntegrationTests.java index f49b91fec5f6..90e5dbccdd4e 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/AbstractHttpHandlerIntegrationTests.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/AbstractHttpHandlerIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,10 +26,10 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Named; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.extension.TestExecutionExceptionHandler; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import reactor.core.publisher.Flux; @@ -37,7 +37,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.client.HttpServerErrorException; -import static org.junit.jupiter.api.Named.named; +import static org.junit.jupiter.params.provider.Arguments.argumentSet; public abstract class AbstractHttpHandlerIntegrationTests { @@ -100,7 +100,7 @@ void stopServer() { /** * Return an interval stream of N number of ticks and buffer the emissions - * to avoid back pressure failures (e.g. on slow CI server). + * to avoid back pressure failures (for example, on slow CI server). * *

            Use this method as follows: *

              @@ -117,18 +117,19 @@ public static Flux testInterval(Duration period, int count) { @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) - @ParameterizedTest(name = "[{index}] {0}") + @ParameterizedTest @MethodSource("org.springframework.web.testfixture.http.server.reactive.bootstrap.AbstractHttpHandlerIntegrationTests#httpServers()") // public for Kotlin public @interface ParameterizedHttpServerTest { } - static Stream> httpServers() { + static Stream httpServers() { return Stream.of( - named("Jetty", new JettyHttpServer()), - named("Reactor Netty", new ReactorHttpServer()), - named("Tomcat", new TomcatHttpServer()), - named("Undertow", new UndertowHttpServer()) + argumentSet("Jetty", new JettyHttpServer()), + argumentSet("Jetty Core", new JettyCoreHttpServer()), + argumentSet("Reactor Netty", new ReactorHttpServer()), + argumentSet("Tomcat", new TomcatHttpServer()), + argumentSet("Undertow", new UndertowHttpServer()) ); } diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/JettyCoreHttpServer.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/JettyCoreHttpServer.java new file mode 100644 index 000000000000..963924f12521 --- /dev/null +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/JettyCoreHttpServer.java @@ -0,0 +1,98 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.testfixture.http.server.reactive.bootstrap; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.eclipse.jetty.io.ArrayByteBufferPool; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.websocket.server.ServerWebSocketContainer; + +import org.springframework.http.server.reactive.JettyCoreHttpHandlerAdapter; + +/** + * @author Rossen Stoyanchev + * @author Sam Brannen + * @author Greg Wilkins + * @since 6.2 + */ +public class JettyCoreHttpServer extends AbstractHttpServer { + + protected Log logger = LogFactory.getLog(getClass().getName()); + + private ArrayByteBufferPool byteBufferPool; + + private Server jettyServer; + + @Override + protected void initServer() { + if (logger.isTraceEnabled()) + this.byteBufferPool = new ArrayByteBufferPool.Tracking(); + this.jettyServer = new Server(null, null, byteBufferPool); + + ServerConnector connector = new ServerConnector(this.jettyServer); + connector.setHost(getHost()); + connector.setPort(getPort()); + this.jettyServer.addConnector(connector); + this.jettyServer.setHandler(createHandlerAdapter()); + + ServerWebSocketContainer.ensure(jettyServer); + } + + private JettyCoreHttpHandlerAdapter createHandlerAdapter() { + return new JettyCoreHttpHandlerAdapter(resolveHttpHandler()); + } + + @Override + protected void startInternal() throws Exception { + this.jettyServer.start(); + setPort(((ServerConnector) this.jettyServer.getConnectors()[0]).getLocalPort()); + } + + @Override + protected void stopInternal() { + boolean wasRunning = this.jettyServer.isRunning(); + try { + this.jettyServer.stop(); + } + catch (Exception ex) { + // ignore + } + + // TODO remove this or make debug only + if (wasRunning && this.byteBufferPool instanceof ArrayByteBufferPool.Tracking tracking) { + if (!tracking.getLeaks().isEmpty()) { + System.err.println("Leaks:\n" + tracking.dumpLeaks()); + throw new IllegalStateException("LEAKS"); + } + } + } + + @Override + protected void resetInternal() { + try { + if (this.jettyServer.isRunning()) { + stopInternal(); + } + this.jettyServer.destroy(); + } + finally { + this.jettyServer = null; + } + } +} diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/JettyHttpServer.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/JettyHttpServer.java index d3912ac2df8c..12878528ff1f 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/JettyHttpServer.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/JettyHttpServer.java @@ -54,7 +54,6 @@ protected void initServer() throws Exception { connector.setPort(getPort()); this.jettyServer.addConnector(connector); this.jettyServer.setHandler(this.contextHandler); - this.contextHandler.start(); } private ServletHttpHandlerAdapter createServletAdapter() { @@ -70,24 +69,10 @@ protected void startInternal() throws Exception { @Override protected void stopInternal() throws Exception { try { - if (this.contextHandler.isRunning()) { - this.contextHandler.stop(); - } + this.jettyServer.stop(); } - finally { - try { - if (this.jettyServer.isRunning()) { - // Do not configure a large stop timeout. For example, setting a stop timeout - // of 5000 adds an additional 1-2 seconds to the runtime of each test using - // the Jetty sever, resulting in 2-4 extra minutes of overall build time. - this.jettyServer.setStopTimeout(100); - this.jettyServer.stop(); - this.jettyServer.destroy(); - } - } - catch (Exception ex) { - // ignore - } + catch (Exception ex) { + // ignore } } @@ -95,18 +80,14 @@ protected void stopInternal() throws Exception { protected void resetInternal() { try { if (this.jettyServer.isRunning()) { - // Do not configure a large stop timeout. For example, setting a stop timeout - // of 5000 adds an additional 1-2 seconds to the runtime of each test using - // the Jetty sever, resulting in 2-4 extra minutes of overall build time. - this.jettyServer.setStopTimeout(100); this.jettyServer.stop(); - this.jettyServer.destroy(); } } catch (Exception ex) { throw new IllegalStateException(ex); } finally { + this.jettyServer.destroy(); this.jettyServer = null; this.contextHandler = null; } diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/ReactorHttpsServer.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/ReactorHttpsServer.java index 7db445489c68..66e23ce216de 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/ReactorHttpsServer.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/ReactorHttpsServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author 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,9 +19,11 @@ import java.net.InetSocketAddress; import java.util.concurrent.atomic.AtomicReference; +import io.netty.handler.ssl.SslContextBuilder; import io.netty.handler.ssl.util.SelfSignedCertificate; import reactor.netty.DisposableServer; import reactor.netty.http.Http11SslContextSpec; +import reactor.netty.tcp.SslProvider.GenericSslContextSpec; import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; @@ -40,7 +42,8 @@ public class ReactorHttpsServer extends AbstractHttpServer { @Override protected void initServer() throws Exception { SelfSignedCertificate cert = new SelfSignedCertificate(); - Http11SslContextSpec http11SslContextSpec = Http11SslContextSpec.forServer(cert.certificate(), cert.privateKey()); + GenericSslContextSpec http11SslContextSpec = + Http11SslContextSpec.forServer(cert.certificate(), cert.privateKey()); this.reactorHandler = createHttpHandlerAdapter(); this.reactorServer = reactor.netty.http.server.HttpServer.create() diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockCookie.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockCookie.java index 8183675a4b9b..9d01ad1c6d61 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockCookie.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockCookie.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,9 +43,17 @@ public class MockCookie extends Cookie { private static final long serialVersionUID = 4312531139502726325L; + private static final String PATH = "Path"; + private static final String DOMAIN = "Domain"; + private static final String COMMENT = "Comment"; + private static final String SECURE = "Secure"; + private static final String HTTP_ONLY = "HttpOnly"; + private static final String PARTITIONED = "Partitioned"; private static final String SAME_SITE = "SameSite"; + private static final String MAX_AGE = "Max-Age"; private static final String EXPIRES = "Expires"; + @Nullable private ZonedDateTime expires; @@ -98,6 +106,29 @@ public String getSameSite() { return getAttribute(SAME_SITE); } + /** + * Set the "Partitioned" attribute for this cookie. + * @since 6.2 + * @see The Partitioned attribute spec + */ + public void setPartitioned(boolean partitioned) { + if (partitioned) { + setAttribute(PARTITIONED, ""); + } + else { + setAttribute(PARTITIONED, null); + } + } + + /** + * Return whether the "Partitioned" attribute is set for this cookie. + * @since 6.2 + * @see The Partitioned attribute spec + */ + public boolean isPartitioned() { + return getAttribute(PARTITIONED) != null; + } + /** * Factory method that parses the value of the supplied "Set-Cookie" header. * @param setCookieHeader the "Set-Cookie" value; never {@code null} or empty @@ -116,10 +147,10 @@ public static MockCookie parse(String setCookieHeader) { MockCookie cookie = new MockCookie(name, value); for (String attribute : attributes) { - if (StringUtils.startsWithIgnoreCase(attribute, "Domain")) { + if (StringUtils.startsWithIgnoreCase(attribute, DOMAIN)) { cookie.setDomain(extractAttributeValue(attribute, setCookieHeader)); } - else if (StringUtils.startsWithIgnoreCase(attribute, "Max-Age")) { + else if (StringUtils.startsWithIgnoreCase(attribute, MAX_AGE)) { cookie.setMaxAge(Integer.parseInt(extractAttributeValue(attribute, setCookieHeader))); } else if (StringUtils.startsWithIgnoreCase(attribute, EXPIRES)) { @@ -131,21 +162,24 @@ else if (StringUtils.startsWithIgnoreCase(attribute, EXPIRES)) { // ignore invalid date formats } } - else if (StringUtils.startsWithIgnoreCase(attribute, "Path")) { + else if (StringUtils.startsWithIgnoreCase(attribute, PATH)) { cookie.setPath(extractAttributeValue(attribute, setCookieHeader)); } - else if (StringUtils.startsWithIgnoreCase(attribute, "Secure")) { + else if (StringUtils.startsWithIgnoreCase(attribute, SECURE)) { cookie.setSecure(true); } - else if (StringUtils.startsWithIgnoreCase(attribute, "HttpOnly")) { + else if (StringUtils.startsWithIgnoreCase(attribute, HTTP_ONLY)) { cookie.setHttpOnly(true); } else if (StringUtils.startsWithIgnoreCase(attribute, SAME_SITE)) { cookie.setSameSite(extractAttributeValue(attribute, setCookieHeader)); } - else if (StringUtils.startsWithIgnoreCase(attribute, "Comment")) { + else if (StringUtils.startsWithIgnoreCase(attribute, COMMENT)) { cookie.setComment(extractAttributeValue(attribute, setCookieHeader)); } + else if (!attribute.isEmpty()) { + cookie.setAttribute(attribute, extractOptionalAttributeValue(attribute, setCookieHeader)); + } } return cookie; } @@ -157,6 +191,11 @@ private static String extractAttributeValue(String attribute, String header) { return nameAndValue[1]; } + private static String extractOptionalAttributeValue(String attribute, String header) { + String[] nameAndValue = attribute.split("="); + return nameAndValue.length == 2 ? nameAndValue[1] : ""; + } + @Override public void setAttribute(String name, @Nullable String value) { if (EXPIRES.equalsIgnoreCase(name)) { @@ -170,14 +209,15 @@ public String toString() { return new ToStringCreator(this) .append("name", getName()) .append("value", getValue()) - .append("Path", getPath()) - .append("Domain", getDomain()) + .append(PATH, getPath()) + .append(DOMAIN, getDomain()) .append("Version", getVersion()) - .append("Comment", getComment()) - .append("Secure", getSecure()) - .append("HttpOnly", isHttpOnly()) + .append(COMMENT, getComment()) + .append(SECURE, getSecure()) + .append(HTTP_ONLY, isHttpOnly()) + .append(PARTITIONED, isPartitioned()) .append(SAME_SITE, getSameSite()) - .append("Max-Age", getMaxAge()) + .append(MAX_AGE, getMaxAge()) .append(EXPIRES, getAttribute(EXPIRES)) .toString(); } diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockFilterRegistration.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockFilterRegistration.java new file mode 100644 index 000000000000..9a165b1a9513 --- /dev/null +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockFilterRegistration.java @@ -0,0 +1,140 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.testfixture.servlet; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.FilterRegistration; + +import org.springframework.lang.Nullable; + +/** + * Mock implementation of {@link FilterRegistration}. + * + * @author Rossen Stoyanchev + * @since 6.2 + */ +public class MockFilterRegistration implements FilterRegistration.Dynamic { + + private final String name; + + private final String className; + + private final Map initParameters = new LinkedHashMap<>(); + + private final List servletNames = new ArrayList<>(); + + private final List urlPatterns = new ArrayList<>(); + + private boolean asyncSupported; + + + public MockFilterRegistration(String className) { + this(className, ""); + } + + public MockFilterRegistration(String className, String name) { + this.name = name; + this.className = className; + } + + + @Override + public String getName() { + return this.name; + } + + @Nullable + @Override + public String getClassName() { + return this.className; + } + + @Override + public boolean setInitParameter(String name, String value) { + return (this.initParameters.putIfAbsent(name, value) != null); + } + + @Nullable + @Override + public String getInitParameter(String name) { + return this.initParameters.get(name); + } + + @Override + public Set setInitParameters(Map initParameters) { + Set existingParameterNames = new LinkedHashSet<>(); + for (Map.Entry entry : initParameters.entrySet()) { + if (this.initParameters.get(entry.getKey()) != null) { + existingParameterNames.add(entry.getKey()); + } + } + if (existingParameterNames.isEmpty()) { + this.initParameters.putAll(initParameters); + } + return existingParameterNames; + } + + @Override + public Map getInitParameters() { + return Collections.unmodifiableMap(this.initParameters); + } + + @Override + public void addMappingForServletNames( + EnumSet dispatcherTypes, boolean isMatchAfter, String... servletNames) { + + this.servletNames.addAll(Arrays.asList(servletNames)); + } + + @Override + public Collection getServletNameMappings() { + return Collections.unmodifiableCollection(this.servletNames); + } + + @Override + public void addMappingForUrlPatterns( + EnumSet dispatcherTypes, boolean isMatchAfter, String... urlPatterns) { + + this.urlPatterns.addAll(Arrays.asList(urlPatterns)); + } + + @Override + public Collection getUrlPatternMappings() { + return Collections.unmodifiableCollection(this.urlPatterns); + } + + @Override + public void setAsyncSupported(boolean asyncSupported) { + this.asyncSupported = asyncSupported; + } + + public boolean isAsyncSupported() { + return this.asyncSupported; + } + +} diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletRequest.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletRequest.java index 31ac9d148223..c909946b92aa 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletRequest.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletRequest.java @@ -102,9 +102,6 @@ public class MockHttpServletRequest implements HttpServletRequest { private static final TimeZone GMT = TimeZone.getTimeZone("GMT"); - private static final BufferedReader EMPTY_BUFFERED_READER = - new BufferedReader(new StringReader("")); - /** * Date formats as specified in the HTTP RFC. * @see Section 7.1.1.1 of RFC 7231 @@ -259,6 +256,9 @@ public class MockHttpServletRequest implements HttpServletRequest { @Nullable private String requestedSessionId; + @Nullable + private String uriTemplate; + @Nullable private String requestURI; @@ -385,6 +385,7 @@ protected void checkActive() throws IllegalStateException { // --------------------------------------------------------------------- @Override + @Nullable public Object getAttribute(String name) { checkActive(); return this.attributes.get(name); @@ -639,6 +640,7 @@ public Enumeration getParameterNames() { } @Override + @Nullable public String[] getParameterValues(String name) { Assert.notNull(name, "Parameter name must not be null"); return this.parameters.get(name); @@ -738,7 +740,7 @@ else if (this.inputStream != null) { this.reader = new BufferedReader(sourceReader); } else { - this.reader = EMPTY_BUFFERED_READER; + this.reader = new BufferedReader(new StringReader("")); } return this.reader; } @@ -1285,6 +1287,24 @@ public String getRequestedSessionId() { return this.requestedSessionId; } + /** + * Set the original URI template used to prepare the request, if any. + * @param uriTemplate the URI template used to set up the request, if any + * @since 6.2 + */ + public void setUriTemplate(@Nullable String uriTemplate) { + this.uriTemplate = uriTemplate; + } + + /** + * Return the original URI template used to prepare the request, if any. + * @since 6.2 + */ + @Nullable + public String getUriTemplate() { + return this.uriTemplate; + } + public void setRequestURI(@Nullable String requestURI) { this.requestURI = requestURI; } @@ -1440,7 +1460,7 @@ public HttpServletMapping getHttpServletMapping() { } /** - * Best effort to detect a Servlet path mapping, e.g. {@code "/foo/*"}, by + * Best effort to detect a Servlet path mapping, for example, {@code "/foo/*"}, by * checking whether the length of requestURI > contextPath + servletPath. * This helps {@link org.springframework.web.util.ServletRequestPathUtils} * to take into account the Servlet path when parsing the requestURI. 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 9ced4d59b1ad..cbcff8f57361 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 @@ -24,6 +24,7 @@ import java.io.UnsupportedEncodingException; import java.io.Writer; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; @@ -72,6 +73,8 @@ public class MockHttpServletResponse implements HttpServletResponse { private static final TimeZone GMT = TimeZone.getTimeZone("GMT"); + private static final MediaType APPLICATION_PLUS_JSON = new MediaType("application", "*+json"); + //--------------------------------------------------------------------- // ServletResponse properties @@ -348,6 +351,10 @@ public void setContentType(@Nullable String contentType) { if (mediaType.getCharset() != null) { setExplicitCharacterEncoding(mediaType.getCharset().name()); } + else if (mediaType.isCompatibleWith(MediaType.APPLICATION_JSON) || + mediaType.isCompatibleWith(APPLICATION_PLUS_JSON)) { + this.characterEncoding = StandardCharsets.UTF_8.name(); + } } catch (Exception ex) { // Try to get charset value anyway @@ -481,6 +488,9 @@ else if (expires != null) { if (cookie.isHttpOnly()) { buf.append("; HttpOnly"); } + if (cookie.getAttribute("Partitioned") != null) { + buf.append("; Partitioned"); + } if (cookie instanceof MockCookie mockCookie) { if (StringUtils.hasText(mockCookie.getSameSite())) { buf.append("; SameSite=").append(mockCookie.getSameSite()); @@ -623,10 +633,15 @@ public void sendError(int status) throws IOException { @Override public void sendRedirect(String url) throws IOException { + sendRedirect(url, HttpServletResponse.SC_MOVED_TEMPORARILY, true); + } + + // @Override - on Servlet 6.1 + public void sendRedirect(String url, int sc, boolean clearBuffer) throws IOException { Assert.state(!isCommitted(), "Cannot send redirect - response is already committed"); Assert.notNull(url, "Redirect URL must not be null"); setHeader(HttpHeaders.LOCATION, url); - setStatus(HttpServletResponse.SC_MOVED_TEMPORARILY); + setStatus(sc); setCommitted(true); } diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockServletContext.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockServletContext.java index b5630023d090..c7f6f2814994 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockServletContext.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockServletContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,6 +52,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; @@ -92,7 +93,7 @@ public class MockServletContext implements ServletContext { private static final String TEMP_DIR_SYSTEM_PROPERTY = "java.io.tmpdir"; - private static final Set DEFAULT_SESSION_TRACKING_MODES = new LinkedHashSet<>(4); + private static final Set DEFAULT_SESSION_TRACKING_MODES = CollectionUtils.newLinkedHashSet(3); static { DEFAULT_SESSION_TRACKING_MODES.add(SessionTrackingMode.COOKIE); @@ -144,6 +145,8 @@ public class MockServletContext implements ServletContext { @Nullable private String responseCharacterEncoding; + private final Map filterRegistrations = new LinkedHashMap<>(); + private final Map mimeTypes = new LinkedHashMap<>(); @@ -223,6 +226,7 @@ public void registerContext(String contextPath, ServletContext context) { } @Override + @Nullable public ServletContext getContext(String contextPath) { if (this.contextPath.equals(contextPath)) { return this; @@ -303,7 +307,7 @@ public Set getResourcePaths(String path) { if (ObjectUtils.isEmpty(fileList)) { return null; } - Set resourcePaths = new LinkedHashSet<>(fileList.length); + Set resourcePaths = CollectionUtils.newLinkedHashSet(fileList.length); for (String fileEntry : fileList) { String resultPath = actualPath + fileEntry; if (resource.createRelative(fileEntry).getFile().isDirectory()) { @@ -375,6 +379,7 @@ public RequestDispatcher getRequestDispatcher(String path) { } @Override + @Nullable public RequestDispatcher getNamedDispatcher(String path) { return this.namedRequestDispatchers.get(path); } @@ -464,6 +469,7 @@ public String getServerInfo() { } @Override + @Nullable public String getInitParameter(String name) { Assert.notNull(name, "Parameter name must not be null"); return this.initParameters.get(name); @@ -600,6 +606,25 @@ public String getResponseCharacterEncoding() { return this.responseCharacterEncoding; } + /** + * Add a {@link FilterRegistration}. + * @since 6.2 + */ + public void addFilterRegistration(FilterRegistration registration) { + this.filterRegistrations.put(registration.getName(), registration); + } + + @Override + @Nullable + public FilterRegistration getFilterRegistration(String filterName) { + return this.filterRegistrations.get(filterName); + } + + @Override + public Map getFilterRegistrations() { + return Collections.unmodifiableMap(this.filterRegistrations); + } + //--------------------------------------------------------------------- // Unsupported Servlet 3.0 registration methods @@ -674,25 +699,6 @@ public T createFilter(Class c) throws ServletException { throw new UnsupportedOperationException(); } - /** - * This method always returns {@code null}. - * @see jakarta.servlet.ServletContext#getFilterRegistration(java.lang.String) - */ - @Override - @Nullable - public FilterRegistration getFilterRegistration(String filterName) { - return null; - } - - /** - * This method always returns an {@linkplain Collections#emptyMap empty map}. - * @see jakarta.servlet.ServletContext#getFilterRegistrations() - */ - @Override - public Map getFilterRegistrations() { - return Collections.emptyMap(); - } - @Override public void addListener(Class listenerClass) { throw new UnsupportedOperationException(); diff --git a/spring-webflux/spring-webflux.gradle b/spring-webflux/spring-webflux.gradle index d0f4bda0c3b1..c068a450378d 100644 --- a/spring-webflux/spring-webflux.gradle +++ b/spring-webflux/spring-webflux.gradle @@ -27,11 +27,14 @@ dependencies { optional("org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jetty-server") { exclude group: "jakarta.servlet", module: "jakarta.servlet-api" } + optional("org.eclipse.jetty.websocket:jetty-websocket-jetty-client") + optional("org.eclipse.jetty.websocket:jetty-websocket-jetty-server") optional("org.freemarker:freemarker") optional("org.jetbrains.kotlin:kotlin-reflect") optional("org.jetbrains.kotlin:kotlin-stdlib") optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") optional("org.webjars:webjars-locator-core") + optional("org.webjars:webjars-locator-lite") testImplementation(testFixtures(project(":spring-beans"))) testImplementation(testFixtures(project(":spring-core"))) testImplementation(testFixtures(project(":spring-web"))) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java b/spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java index 767ea0a7b2f3..6f9a4b95a568 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,11 +18,8 @@ import java.lang.annotation.Annotation; import java.util.Collection; -import java.util.Collections; import java.util.Map; -import reactor.core.publisher.Mono; - import org.springframework.beans.BeanUtils; import org.springframework.core.MethodParameter; import org.springframework.core.ReactiveAdapterRegistry; @@ -139,7 +136,7 @@ public WebExchangeDataBinder createDataBinder(ServerWebExchange exchange, String public WebExchangeDataBinder createDataBinder( ServerWebExchange exchange, @Nullable Object target, String name, @Nullable ResolvableType targetType) { - WebExchangeDataBinder dataBinder = new ExtendedWebExchangeDataBinder(target, name); + WebExchangeDataBinder dataBinder = createBinderInstance(target, name); dataBinder.setNameResolver(new BindParamNameResolver()); if (target == null && targetType != null) { @@ -161,6 +158,18 @@ public WebExchangeDataBinder createDataBinder( return dataBinder; } + /** + * Extension point to create the WebDataBinder instance. + * By default, this is {@code WebRequestDataBinder}. + * @param target the binding target or {@code null} for type conversion only + * @param name the binding target object name + * @return the created {@link WebExchangeDataBinder} instance + * @since 6.2.1 + */ + protected WebExchangeDataBinder createBinderInstance(@Nullable Object target, String name) { + return new WebExchangeDataBinder(target, name); + } + /** * Initialize the data binder instance for the given exchange. * @throws ServerErrorException if {@code @InitBinder} method invocation fails @@ -198,24 +207,6 @@ private boolean isBindingCandidate(String name, @Nullable Object value) { } - /** - * Extended variant of {@link WebExchangeDataBinder}, adding path variables. - */ - private static class ExtendedWebExchangeDataBinder extends WebExchangeDataBinder { - - public ExtendedWebExchangeDataBinder(@Nullable Object target, String objectName) { - super(target, objectName); - } - - @Override - public Mono> getValuesToBind(ServerWebExchange exchange) { - return super.getValuesToBind(exchange).doOnNext(map -> - map.putAll(exchange.>getAttributeOrDefault( - HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, Collections.emptyMap()))); - } - } - - /** * Excludes Bean Validation if the method parameter has {@code @Valid}. */ diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/DispatcherHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/DispatcherHandler.java index c51a5f5026e3..996c672c00d7 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/DispatcherHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/DispatcherHandler.java @@ -171,9 +171,10 @@ private Mono handleResultMono(ServerWebExchange exchange, Mono { Mono voidMono = handleResult(exchange, result, "Handler " + result.getHandler()); - if (result.getExceptionHandler() != null) { + DispatchExceptionHandler exceptionHandler = result.getExceptionHandler(); + if (exceptionHandler != null) { voidMono = voidMono.onErrorResume(ex -> - result.getExceptionHandler().handleError(exchange, ex).flatMap(result2 -> + exceptionHandler.handleError(exchange, ex).flatMap(result2 -> handleResult(exchange, result2, "Exception handler " + result2.getHandler() + ", error=\"" + ex.getMessage() + "\""))); } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/HandlerResult.java b/spring-webflux/src/main/java/org/springframework/web/reactive/HandlerResult.java index 9844e59f57c8..c9a970fbafc6 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/HandlerResult.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/HandlerResult.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author 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,6 @@ package org.springframework.web.reactive; -import java.util.function.Function; - -import reactor.core.publisher.Mono; - import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; import org.springframework.lang.Nullable; @@ -46,9 +42,6 @@ public class HandlerResult { @Nullable private DispatchExceptionHandler exceptionHandler; - @Nullable - private Function> exceptionHandlerFunction; - /** * Create a new {@code HandlerResult}. @@ -95,7 +88,7 @@ public Object getReturnValue() { } /** - * Return the type of the value returned from the handler -- e.g. the return + * Return the type of the value returned from the handler -- for example, the return * type declared on a controller method's signature. Also see * {@link #getReturnTypeSource()} to obtain the underlying * {@link MethodParameter} for the return type. @@ -149,40 +142,4 @@ public DispatchExceptionHandler getExceptionHandler() { return this.exceptionHandler; } - /** - * {@link HandlerAdapter} classes can set this to have their exception - * handling mechanism applied to response rendering and to deferred - * exceptions when invoking a handler with an asynchronous return value. - * @param function the error handler - * @return the current instance - * @deprecated in favor of {@link #setExceptionHandler(DispatchExceptionHandler)} - */ - @Deprecated(since = "6.0", forRemoval = true) - public HandlerResult setExceptionHandler(Function> function) { - this.exceptionHandler = (exchange, ex) -> function.apply(ex); - this.exceptionHandlerFunction = function; - return this; - } - - /** - * Whether there is an exception handler. - * @deprecated in favor of checking via {@link #getExceptionHandler()} - */ - @Deprecated(since = "6.0", forRemoval = true) - public boolean hasExceptionHandler() { - return (this.exceptionHandler != null); - } - - /** - * Apply the exception handler and return the alternative result. - * @param failure the exception - * @return the new result or the same error if there is no exception handler - * @deprecated without a replacement; for internal invocation only, not used as of 6.0 - */ - @Deprecated(since = "6.0", forRemoval = true) - public Mono applyExceptionHandler(Throwable failure) { - return (this.exceptionHandlerFunction != null ? - this.exceptionHandlerFunction.apply(failure) : Mono.error(failure)); - } - } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ParameterContentTypeResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ParameterContentTypeResolver.java index b003c0352e87..2d077c7f4ec3 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ParameterContentTypeResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ParameterContentTypeResolver.java @@ -40,7 +40,7 @@ */ public class ParameterContentTypeResolver implements RequestedContentTypeResolver { - /** Primary lookup for media types by key (e.g. "json" -> "application/json") */ + /** Primary lookup for media types by key (for example, "json" -> "application/json"). */ private final Map mediaTypes = new ConcurrentHashMap<>(64); private String parameterName = "format"; diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolverBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolverBuilder.java index c004882caf56..b74cc841f959 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolverBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolverBuilder.java @@ -30,7 +30,7 @@ /** * Builder for a composite {@link RequestedContentTypeResolver} that delegates * to other resolvers each implementing a different strategy to determine the - * requested content type -- e.g. Accept header, query parameter, or other. + * requested content type -- for example, Accept header, query parameter, or other. * *

              Use builder methods to add resolvers in the desired order. For a given * request he first resolver to return a list that is not empty and does not diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/CorsRegistration.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/CorsRegistration.java index bb7dccf290b1..8adb3ff101a7 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/CorsRegistration.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/CorsRegistration.java @@ -75,7 +75,7 @@ public CorsRegistration allowedOriginPatterns(String... patterns) { } /** - * Set the HTTP methods to allow, e.g. {@code "GET"}, {@code "POST"}, etc. + * Set the HTTP methods to allow, for example, {@code "GET"}, {@code "POST"}, etc. * The special value {@code "*"} allows all methods. By default, * "simple" methods {@code GET}, {@code HEAD}, and {@code POST} * are allowed. diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/DelegatingWebFluxConfiguration.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/DelegatingWebFluxConfiguration.java index 57d60f12bf9d..bc7ca8d50f99 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/DelegatingWebFluxConfiguration.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/DelegatingWebFluxConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.springframework.util.CollectionUtils; import org.springframework.validation.MessageCodesResolver; import org.springframework.validation.Validator; +import org.springframework.web.ErrorResponse; import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer; import org.springframework.web.reactive.socket.server.WebSocketService; @@ -102,6 +103,12 @@ protected void configureArgumentResolvers(ArgumentResolverConfigurer configurer) this.configurers.configureArgumentResolvers(configurer); } + @Override + protected void configureErrorResponseInterceptors(List interceptors) { + this.configurers.addErrorResponseInterceptors(interceptors); + } + + @Override protected void addResourceHandlers(ResourceHandlerRegistry registry) { this.configurers.addResourceHandlers(registry); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ResourceChainRegistration.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ResourceChainRegistration.java index fe31f1963618..376daf987aa7 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ResourceChainRegistration.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ResourceChainRegistration.java @@ -27,6 +27,7 @@ import org.springframework.web.reactive.resource.CachingResourceResolver; import org.springframework.web.reactive.resource.CachingResourceTransformer; import org.springframework.web.reactive.resource.CssLinkResourceTransformer; +import org.springframework.web.reactive.resource.LiteWebJarsResourceResolver; import org.springframework.web.reactive.resource.PathResourceResolver; import org.springframework.web.reactive.resource.ResourceResolver; import org.springframework.web.reactive.resource.ResourceTransformer; @@ -43,9 +44,12 @@ public class ResourceChainRegistration { private static final String DEFAULT_CACHE_NAME = "spring-resource-chain-cache"; - private static final boolean isWebJarsAssetLocatorPresent = ClassUtils.isPresent( + private static final boolean isWebJarAssetLocatorPresent = ClassUtils.isPresent( "org.webjars.WebJarAssetLocator", ResourceChainRegistration.class.getClassLoader()); + private static final boolean isWebJarVersionLocatorPresent = ClassUtils.isPresent( + "org.webjars.WebJarVersionLocator", ResourceChainRegistration.class.getClassLoader()); + private final List resolvers = new ArrayList<>(4); @@ -64,6 +68,7 @@ public ResourceChainRegistration(boolean cacheResources) { this(cacheResources, cacheResources ? new ConcurrentMapCache(DEFAULT_CACHE_NAME) : null); } + @SuppressWarnings("NullAway") public ResourceChainRegistration(boolean cacheResources, @Nullable Cache cache) { Assert.isTrue(!cacheResources || cache != null, "'cache' is required when cacheResources=true"); if (cacheResources) { @@ -78,6 +83,7 @@ public ResourceChainRegistration(boolean cacheResources, @Nullable Cache cache) * @param resolver the resolver to add * @return the current instance for chained method invocation */ + @SuppressWarnings("removal") public ResourceChainRegistration addResolver(ResourceResolver resolver) { Assert.notNull(resolver, "The provided ResourceResolver should not be null"); this.resolvers.add(resolver); @@ -87,7 +93,7 @@ public ResourceChainRegistration addResolver(ResourceResolver resolver) { else if (resolver instanceof PathResourceResolver) { this.hasPathResolver = true; } - else if (resolver instanceof WebJarsResourceResolver) { + else if (resolver instanceof WebJarsResourceResolver || resolver instanceof LiteWebJarsResourceResolver) { this.hasWebjarsResolver = true; } return this; @@ -107,10 +113,14 @@ public ResourceChainRegistration addTransformer(ResourceTransformer transformer) return this; } + @SuppressWarnings("removal") protected List getResourceResolvers() { if (!this.hasPathResolver) { List result = new ArrayList<>(this.resolvers); - if (isWebJarsAssetLocatorPresent && !this.hasWebjarsResolver) { + if (isWebJarVersionLocatorPresent && !this.hasWebjarsResolver) { + result.add(new LiteWebJarsResourceResolver()); + } + else if (isWebJarAssetLocatorPresent && !this.hasWebjarsResolver) { result.add(new WebJarsResourceResolver()); } result.add(new PathResourceResolver()); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ResourceHandlerRegistry.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ResourceHandlerRegistry.java index a712bf4b9b1f..a84fdcc6aa85 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ResourceHandlerRegistry.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ResourceHandlerRegistry.java @@ -43,11 +43,11 @@ * *

              To create a resource handler, use {@link #addResourceHandler(String...)} * providing the URL path patterns for which the handler should be invoked to - * serve static resources (e.g. {@code "/resources/**"}). + * serve static resources (for example, {@code "/resources/**"}). * *

              Then use additional methods on the returned * {@link ResourceHandlerRegistration} to add one or more locations from which - * to serve static content from (e.g. {{@code "/"}, + * to serve static content from (for example, {{@code "/"}, * {@code "classpath:/META-INF/public-web-resources/"}}) or to specify a cache * period for served resources. * diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ViewResolverRegistry.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ViewResolverRegistry.java index af695751d1b8..9b603e0637ba 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ViewResolverRegistry.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ViewResolverRegistry.java @@ -40,7 +40,7 @@ * different template mechanisms. * *

              In addition, you can also configure {@link #defaultViews(View...) - * defaultViews} for rendering according to the requested content type, e.g. + * defaultViews} for rendering according to the requested content type, for example, * JSON, XML, etc. * * @author Rossen Stoyanchev @@ -123,7 +123,7 @@ public void viewResolver(ViewResolver viewResolver) { * best match for the requested content type. *

              Use {@link HttpMessageWriterView * HttpMessageWriterView} to adapt and use any existing - * {@code HttpMessageWriter} (e.g. JSON, XML) as a {@code View}. + * {@code HttpMessageWriter} (for example, JSON, XML) as a {@code View}. */ public void defaultViews(View... defaultViews) { this.defaultViews.addAll(Arrays.asList(defaultViews)); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java index ac71dc84675d..eccef7cfe93b 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.web.reactive.config; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Predicate; @@ -44,6 +45,7 @@ import org.springframework.validation.MessageCodesResolver; import org.springframework.validation.Validator; import org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean; +import org.springframework.web.ErrorResponse; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; import org.springframework.web.cors.CorsConfiguration; @@ -98,6 +100,9 @@ public class WebFluxConfigurationSupport implements ApplicationContextAware { @Nullable private BlockingExecutionConfigurer blockingExecutionConfigurer; + @Nullable + private List errorResponseInterceptors; + @Nullable private ViewResolverRegistry viewResolverRegistry; @@ -111,7 +116,7 @@ public void setApplicationContext(@Nullable ApplicationContext applicationContex if (applicationContext != null) { Assert.state(!applicationContext.containsBean("mvcContentNegotiationManager"), "The Java/XML config for Spring MVC and Spring WebFlux cannot both be enabled, " + - "e.g. via @EnableWebMvc and @EnableWebFlux, in the same application."); + "for example, via @EnableWebMvc and @EnableWebFlux, in the same application."); } } @@ -278,12 +283,14 @@ public RequestMappingHandlerAdapter requestMappingHandlerAdapter( @Qualifier("webFluxAdapterRegistry") ReactiveAdapterRegistry reactiveAdapterRegistry, ServerCodecConfigurer serverCodecConfigurer, @Qualifier("webFluxConversionService") FormattingConversionService conversionService, + @Qualifier("webFluxContentTypeResolver") RequestedContentTypeResolver contentTypeResolver, @Qualifier("webFluxValidator") Validator validator) { RequestMappingHandlerAdapter adapter = createRequestMappingHandlerAdapter(); adapter.setMessageReaders(serverCodecConfigurer.getReaders()); adapter.setWebBindingInitializer(getConfigurableWebBindingInitializer(conversionService, validator)); adapter.setReactiveAdapterRegistry(reactiveAdapterRegistry); + adapter.setContentTypeResolver(contentTypeResolver); BlockingExecutionConfigurer executorConfigurer = getBlockingExecutionConfigurer(); if (executorConfigurer.getExecutor() != null) { @@ -478,9 +485,9 @@ private WebSocketService initWebSocketService() { try { service = new HandshakeWebSocketService(); } - catch (IllegalStateException ex) { + catch (Throwable ex) { // Don't fail, test environment perhaps - service = new NoUpgradeStrategyWebSocketService(); + service = new NoUpgradeStrategyWebSocketService(ex); } } return service; @@ -498,7 +505,7 @@ public ResponseEntityResultHandler responseEntityResultHandler( @Qualifier("webFluxContentTypeResolver") RequestedContentTypeResolver contentTypeResolver) { return new ResponseEntityResultHandler(serverCodecConfigurer.getWriters(), - contentTypeResolver, reactiveAdapterRegistry); + contentTypeResolver, reactiveAdapterRegistry, getErrorResponseInterceptors()); } @Bean @@ -508,7 +515,7 @@ public ResponseBodyResultHandler responseBodyResultHandler( @Qualifier("webFluxContentTypeResolver") RequestedContentTypeResolver contentTypeResolver) { return new ResponseBodyResultHandler(serverCodecConfigurer.getWriters(), - contentTypeResolver, reactiveAdapterRegistry); + contentTypeResolver, reactiveAdapterRegistry, getErrorResponseInterceptors()); } @Bean @@ -534,6 +541,29 @@ public ServerResponseResultHandler serverResponseResultHandler(ServerCodecConfig return handler; } + /** + * Provide access to the list of {@link ErrorResponse.Interceptor}'s to apply + * in result handlers when rendering error responses. + *

              This method cannot be overridden; use {@link #configureErrorResponseInterceptors(List)} instead. + * @since 6.2 + */ + protected final List getErrorResponseInterceptors() { + if (this.errorResponseInterceptors == null) { + this.errorResponseInterceptors = new ArrayList<>(); + configureErrorResponseInterceptors(this.errorResponseInterceptors); + } + return this.errorResponseInterceptors; + } + + /** + * Override this method for control over the {@link ErrorResponse.Interceptor}'s + * to apply in result handling when rendering error responses. + * @param interceptors the list to add handlers to + * @since 6.2 + */ + protected void configureErrorResponseInterceptors(List interceptors) { + } + /** * Callback for building the {@link ViewResolverRegistry}. This method is final, * use {@link #configureViewResolvers} to customize view resolvers. @@ -578,9 +608,15 @@ public void validate(@Nullable Object target, Errors errors) { private static final class NoUpgradeStrategyWebSocketService implements WebSocketService { + private final Throwable ex; + + public NoUpgradeStrategyWebSocketService(Throwable ex) { + this.ex = ex; + } + @Override public Mono handleRequest(ServerWebExchange exchange, WebSocketHandler webSocketHandler) { - return Mono.error(new IllegalStateException("No suitable RequestUpgradeStrategy")); + return Mono.error(new IllegalStateException("No suitable RequestUpgradeStrategy", this.ex)); } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurer.java index a89c48131473..01f244cfffa1 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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.web.reactive.config; +import java.util.List; + import org.springframework.core.convert.converter.Converter; import org.springframework.format.Formatter; import org.springframework.format.FormatterRegistry; @@ -23,6 +25,7 @@ import org.springframework.lang.Nullable; import org.springframework.validation.MessageCodesResolver; import org.springframework.validation.Validator; +import org.springframework.web.ErrorResponse; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer; @@ -133,10 +136,20 @@ default void configurePathMatching(PathMatchConfigurer configurer) { default void configureArgumentResolvers(ArgumentResolverConfigurer configurer) { } + /** + * Add to the list of {@link ErrorResponse.Interceptor}'s to invoke when + * rendering an RFC 9457 {@link org.springframework.http.ProblemDetail} + * error response. + * @param interceptors the handlers to use + * @since 6.2 + */ + default void addErrorResponseInterceptors(List interceptors) { + } + /** * Configure view resolution for rendering responses with a view and a model, * where the view is typically an HTML template but could also be based on - * an HTTP message writer (e.g. JSON, XML). + * an HTTP message writer (for example, JSON, XML). *

              The configured view resolvers will be used for both annotated * controllers and functional endpoints. */ diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurerComposite.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurerComposite.java index 21888fc28d99..e8efac6cfd31 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurerComposite.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurerComposite.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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 org.springframework.util.CollectionUtils; import org.springframework.validation.MessageCodesResolver; import org.springframework.validation.Validator; +import org.springframework.web.ErrorResponse; import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer; import org.springframework.web.reactive.socket.server.WebSocketService; @@ -97,6 +98,13 @@ public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) { this.delegates.forEach(delegate -> delegate.configureArgumentResolvers(configurer)); } + @Override + public void addErrorResponseInterceptors(List interceptors) { + for (WebFluxConfigurer delegate : this.delegates) { + delegate.addErrorResponseInterceptors(interceptors); + } + } + @Override public void configureViewResolvers(ViewResolverRegistry registry) { this.delegates.forEach(delegate -> delegate.configureViewResolvers(registry)); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyExtractors.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyExtractors.java index 1c426b157863..998dbe02bafd 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyExtractors.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyExtractors.java @@ -122,7 +122,7 @@ private static BodyExtractor, ReactiveHttpInputMessage> toFlux(Resol /** * Extractor to read form data into {@code MultiValueMap}. *

              As of 5.1 this method can also be used on the client side to read form - * data from a server response (e.g. OAuth). + * data from a server response (for example, OAuth). * @return {@code BodyExtractor} for form data */ public static BodyExtractor>, ReactiveHttpInputMessage> toFormData() { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyInserters.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyInserters.java index 35d9a1be2a59..96fac2afbf04 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyInserters.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyInserters.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -103,6 +103,32 @@ public static BodyInserter fromValue(T body) { writeWithMessageWriters(message, context, Mono.just(body), ResolvableType.forInstance(body), null); } + /** + * Inserter to write the given value. + *

              Alternatively, consider using the {@code bodyValue(Object, ParameterizedTypeReference)} shortcuts on + * {@link org.springframework.web.reactive.function.client.WebClient WebClient} and + * {@link org.springframework.web.reactive.function.server.ServerResponse ServerResponse}. + * @param body the value to write + * @param bodyType the type of the body, used to capture the generic type + * @param the type of the body + * @return the inserter to write a single value + * @throws IllegalArgumentException if {@code body} is a {@link Publisher} or an + * instance of a type supported by {@link ReactiveAdapterRegistry#getSharedInstance()}, + * for which {@link #fromPublisher(Publisher, ParameterizedTypeReference)} or + * {@link #fromProducer(Object, ParameterizedTypeReference)} should be used. + * @since 6.2 + * @see #fromPublisher(Publisher, ParameterizedTypeReference) + * @see #fromProducer(Object, ParameterizedTypeReference) + */ + public static BodyInserter fromValue(T body, ParameterizedTypeReference bodyType) { + Assert.notNull(body, "'body' must not be null"); + Assert.notNull(bodyType, "'bodyType' must not be null"); + Assert.isNull(registry.getAdapter(body.getClass()), + "'body' should be an object, for reactive types use a variant specifying a publisher/producer and its related element type"); + return (message, context) -> + writeWithMessageWriters(message, context, Mono.just(body), ResolvableType.forType(bodyType), null); + } + /** * Inserter to write the given object. *

              Alternatively, consider using the {@code bodyValue(Object)} shortcuts on diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientHttpObservationDocumentation.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientHttpObservationDocumentation.java index e7bbd8af3f4b..5f02518338c4 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientHttpObservationDocumentation.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientHttpObservationDocumentation.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author 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,8 +23,11 @@ import io.micrometer.observation.docs.ObservationDocumentation; /** - * Documented {@link io.micrometer.common.KeyValue KeyValues} for the {@link WebClient HTTP client} observations. - *

              This class is used by automated tools to document KeyValues attached to the HTTP client observations. + * Documented {@link io.micrometer.common.KeyValue KeyValues} for the + * {@link WebClient HTTP client} observations. + * + *

              This class is used by automated tools to document KeyValues attached to the + * HTTP client observations. * * @author Brian Clozel * @since 6.0 @@ -49,25 +52,26 @@ public KeyName[] getLowCardinalityKeyNames() { public KeyName[] getHighCardinalityKeyNames() { return new KeyName[] {HighCardinalityKeyNames.HTTP_URL}; } - }; + public enum LowCardinalityKeyNames implements KeyName { /** - * Name of HTTP request method or {@value KeyValue#NONE_VALUE} if the request could not be created. + * Name of HTTP request method or {@value KeyValue#NONE_VALUE} if the + * request could not be created. */ METHOD { @Override public String asString() { return "method"; } - }, /** - * URI template used for HTTP request, or {@value KeyValue#NONE_VALUE} if none was provided. - * Only the path part of the URI is considered. + * URI template used for HTTP request, or {@value KeyValue#NONE_VALUE} if + * none was provided. + *

              The protocol, host and port part of the URI are not considered. */ URI { @Override @@ -99,7 +103,8 @@ public String asString() { }, /** - * Name of the exception thrown during the exchange, or {@value KeyValue#NONE_VALUE} if no exception happened. + * Name of the exception thrown during the exchange, or + * {@value KeyValue#NONE_VALUE} if no exception happened. */ EXCEPTION { @Override @@ -110,7 +115,6 @@ public String asString() { /** * Outcome of the HTTP client exchange. - * * @see org.springframework.http.HttpStatus.Series */ OUTCOME { @@ -132,20 +136,6 @@ public enum HighCardinalityKeyNames implements KeyName { public String asString() { return "http.url"; } - }, - - /** - * Client name derived from the request URI host. - * @deprecated in favor of {@link LowCardinalityKeyNames#CLIENT_NAME}; - * scheduled for removal in 6.2. This will be available both as a low and - * high cardinality key value. - */ - @Deprecated(since = "6.0.5", forRemoval = true) - CLIENT_NAME { - @Override - public String asString() { - return "client.name"; - } } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientRequestBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientRequestBuilder.java index f83d924d1664..5e710a9dbca1 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientRequestBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientRequestBuilder.java @@ -266,6 +266,9 @@ public Mono writeTo(ClientHttpRequest request, ExchangeStrategies strategi requestCookies.add(name, cookie); })); } + + request.getAttributes().putAll(this.attributes); + if (this.httpRequestConsumer != null) { this.httpRequestConsumer.accept(request); } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientResponseBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientResponseBuilder.java index e089b1f3ccb2..0a49c0897143 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientResponseBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientResponseBuilder.java @@ -18,6 +18,8 @@ import java.net.URI; import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; @@ -65,6 +67,11 @@ public URI getURI() { public HttpHeaders getHeaders() { return HttpHeaders.EMPTY; } + + @Override + public Map getAttributes() { + return Collections.emptyMap(); + } }; @@ -137,10 +144,10 @@ public ClientResponse.Builder headers(Consumer headersConsumer) { return this; } - @SuppressWarnings("ConstantConditions") + @SuppressWarnings({"ConstantConditions", "NullAway"}) private HttpHeaders getHeaders() { if (this.headers == null) { - this.headers = HttpHeaders.writableHttpHeaders(this.originalResponse.headers().asHttpHeaders()); + this.headers = new HttpHeaders(this.originalResponse.headers().asHttpHeaders()); } return this.headers; } @@ -159,7 +166,7 @@ public ClientResponse.Builder cookies(Consumer getCookies() { if (this.cookies == null) { this.cookies = new LinkedMultiValueMap<>(this.originalResponse.cookies()); @@ -256,13 +263,13 @@ public HttpStatusCode getStatusCode() { } @Override - @SuppressWarnings("ConstantConditions") + @SuppressWarnings({"ConstantConditions", "NullAway"}) public HttpHeaders getHeaders() { return (this.headers != null ? this.headers : this.originalResponse.headers().asHttpHeaders()); } @Override - @SuppressWarnings("ConstantConditions") + @SuppressWarnings({"ConstantConditions", "NullAway"}) public MultiValueMap getCookies() { return (this.cookies != null ? this.cookies : this.originalResponse.cookies()); } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java index 5076d7c35198..443ba3018f9a 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java @@ -177,7 +177,11 @@ public RequestBodyUriSpec method(HttpMethod httpMethod) { } private RequestBodyUriSpec methodInternal(HttpMethod httpMethod) { - return new DefaultRequestBodyUriSpec(httpMethod); + DefaultRequestBodyUriSpec spec = new DefaultRequestBodyUriSpec(httpMethod); + if (this.defaultRequest != null) { + this.defaultRequest.accept(spec); + } + return spec; } @Override @@ -363,6 +367,12 @@ public RequestHeadersSpec bodyValue(Object body) { return this; } + @Override + public RequestHeadersSpec bodyValue(T body, ParameterizedTypeReference bodyType) { + this.inserter = BodyInserters.fromValue(body, bodyType); + return this; + } + @Override public > RequestHeadersSpec body( P publisher, ParameterizedTypeReference elementTypeRef) { @@ -478,9 +488,6 @@ public Mono exchange() { } private ClientRequest.Builder initRequestBuilder() { - if (defaultRequest != null) { - defaultRequest.accept(this); - } ClientRequest.Builder builder = ClientRequest.create(this.httpMethod, initUri()) .headers(this::initHeaders) .cookies(this::initCookies) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java index 59da4e80d02a..b67ace1c82f8 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java @@ -319,18 +319,14 @@ public WebClient build() { .orElse(null) : null); HttpHeaders defaultHeaders = copyDefaultHeaders(); - MultiValueMap defaultCookies = copyDefaultCookies(); - return new DefaultWebClient(exchange, - filterFunctions, - initUriBuilderFactory(), - defaultHeaders, - defaultCookies, + return new DefaultWebClient( + exchange, filterFunctions, + initUriBuilderFactory(), defaultHeaders, defaultCookies, this.defaultRequest, this.statusHandlers, - this.observationRegistry, - this.observationConvention, + this.observationRegistry, this.observationConvention, new DefaultWebClientBuilder(this)); } @@ -374,26 +370,22 @@ private UriBuilderFactory initUriBuilderFactory() { @Nullable private HttpHeaders copyDefaultHeaders() { - if (this.defaultHeaders != null) { - HttpHeaders copy = new HttpHeaders(); - this.defaultHeaders.forEach((key, values) -> copy.put(key, new ArrayList<>(values))); - return HttpHeaders.readOnlyHttpHeaders(copy); - } - else { + if (this.defaultHeaders == null) { return null; } + HttpHeaders headers = new HttpHeaders(); + this.defaultHeaders.forEach((key, values) -> headers.put(key, new ArrayList<>(values))); + return HttpHeaders.readOnlyHttpHeaders(headers); } @Nullable private MultiValueMap copyDefaultCookies() { - if (this.defaultCookies != null) { - MultiValueMap copy = new LinkedMultiValueMap<>(this.defaultCookies.size()); - this.defaultCookies.forEach((key, values) -> copy.put(key, new ArrayList<>(values))); - return CollectionUtils.unmodifiableMultiValueMap(copy); - } - else { + if (this.defaultCookies == null) { return null; } + MultiValueMap map = new LinkedMultiValueMap<>(this.defaultCookies.size()); + this.defaultCookies.forEach((key, values) -> map.put(key, new ArrayList<>(values))); + return CollectionUtils.unmodifiableMultiValueMap(map); } } 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 cb77cac3fd2d..c11c5d51178b 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 @@ -17,6 +17,7 @@ package org.springframework.web.reactive.function.client; import java.net.URI; +import java.util.Map; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -149,6 +150,11 @@ public URI getURI() { return request.url(); } + @Override + public Map getAttributes() { + return request.attributes(); + } + @Override public HttpHeaders getHeaders() { return request.headers(); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java index 0fdf53a8f850..69e07aebf589 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java @@ -291,7 +291,7 @@ Builder defaultStatusHandler(Predicate statusPredicate, /** * Configure the {@link ClientHttpConnector} to use. This is useful for * plugging in and/or customizing options of the underlying HTTP client - * library (e.g. SSL). + * library (for example, SSL). *

              By default this is set to * {@link org.springframework.http.client.reactive.ReactorClientHttpConnector * ReactorClientHttpConnector}. @@ -388,14 +388,14 @@ interface UriSpec> { /** * Specify the URI for the request using a URI template and URI variables. - * If a {@link UriBuilderFactory} was configured for the client (e.g. + * If a {@link UriBuilderFactory} was configured for the client (for example, * with a base URI) it will be used to expand the URI template. */ S uri(String uri, Object... uriVariables); /** * Specify the URI for the request using a URI template and URI variables. - * If a {@link UriBuilderFactory} was configured for the client (e.g. + * If a {@link UriBuilderFactory} was configured for the client (for example, * with a base URI) it will be used to expand the URI template. */ S uri(String uri, Map uriVariables); @@ -692,9 +692,38 @@ interface RequestBodySpec extends RequestHeadersSpec { * @throws IllegalArgumentException if {@code body} is a * {@link Publisher} or producer known to {@link ReactiveAdapterRegistry} * @since 5.2 + * @see #bodyValue(Object, ParameterizedTypeReference) */ RequestHeadersSpec bodyValue(Object body); + /** + * Shortcut for {@link #body(BodyInserter)} with a + * {@linkplain BodyInserters#fromValue value inserter}. + * For example: + *

              +		 * List<Person> list = ... ;
              +		 *
              +		 * Mono<Void> result = client.post()
              +		 *     .uri("/persons/{id}", id)
              +		 *     .contentType(MediaType.APPLICATION_JSON)
              +		 *     .bodyValue(list, new ParameterizedTypeReference<List<Person>>() {};)
              +		 *     .retrieve()
              +		 *     .bodyToMono(Void.class);
              +		 * 
              + *

              For multipart requests consider providing + * {@link org.springframework.util.MultiValueMap MultiValueMap} prepared + * with {@link org.springframework.http.client.MultipartBodyBuilder + * MultipartBodyBuilder}. + * @param body the value to write to the request body + * @param bodyType the type of the body, used to capture the generic type + * @param the type of the body + * @return this builder + * @throws IllegalArgumentException if {@code body} is a + * {@link Publisher} or producer known to {@link ReactiveAdapterRegistry} + * @since 6.2 + */ + RequestHeadersSpec bodyValue(T body, ParameterizedTypeReference bodyType); + /** * Shortcut for {@link #body(BodyInserter)} with a * {@linkplain BodyInserters#fromPublisher Publisher inserter}. diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java index a344bf20a837..03e85490bafa 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -155,16 +155,4 @@ public static WebClientAdapter create(WebClient webClient) { return new WebClientAdapter(webClient); } - /** - * Create a {@link WebClientAdapter} for the given {@code WebClient} instance. - * @param webClient the client to use - * @return the created adapter instance - * @deprecated in favor of {@link #create(WebClient)} aligning with other adapter - * implementations; to be removed in 6.2. - */ - @Deprecated(since = "6.1", forRemoval = true) - public static WebClientAdapter forClient(WebClient webClient) { - return new WebClientAdapter(webClient); - } - } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultEntityResponseBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultEntityResponseBuilder.java index 87697d0da1ef..440e02fc3aad 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultEntityResponseBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultEntityResponseBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -147,14 +147,8 @@ public EntityResponse.Builder contentType(MediaType contentType) { } @Override - public EntityResponse.Builder eTag(String etag) { - if (!etag.startsWith("\"") && !etag.startsWith("W/\"")) { - etag = "\"" + etag; - } - if (!etag.endsWith("\"")) { - etag = etag + "\""; - } - this.headers.setETag(etag); + public EntityResponse.Builder eTag(String tag) { + this.headers.setETag(tag); return this; } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequestBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequestBuilder.java index a97fbd902563..11c979525116 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequestBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequestBuilder.java @@ -193,7 +193,7 @@ public ServerRequest.Builder attributes(Consumer> attributes @Override public ServerRequest build() { ServerHttpRequest serverHttpRequest = new BuiltServerHttpRequest(this.exchange.getRequest().getId(), - this.method, this.uri, this.contextPath, this.headers, this.cookies, this.body); + this.method, this.uri, this.contextPath, this.headers, this.cookies, this.body, this.attributes); ServerWebExchange exchange = new DelegatingServerWebExchange( serverHttpRequest, this.attributes, this.exchange, this.messageReaders); return new DefaultServerRequest(exchange, this.messageReaders); @@ -220,8 +220,10 @@ private static class BuiltServerHttpRequest implements ServerHttpRequest { private final Flux body; + private final Map attributes; + public BuiltServerHttpRequest(String id, HttpMethod method, URI uri, @Nullable String contextPath, - HttpHeaders headers, MultiValueMap cookies, Flux body) { + HttpHeaders headers, MultiValueMap cookies, Flux body, Map attributes) { this.id = id; this.method = method; @@ -231,6 +233,7 @@ public BuiltServerHttpRequest(String id, HttpMethod method, URI uri, @Nullable S this.cookies = unmodifiableCopy(cookies); this.queryParams = parseQueryParams(uri); this.body = body; + this.attributes = attributes; } private static MultiValueMap unmodifiableCopy(MultiValueMap original) { @@ -273,6 +276,11 @@ public URI getURI() { return this.uri; } + @Override + public Map getAttributes() { + return this.attributes; + } + @Override public RequestPath getPath() { return this.path; diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java index 9290ca5a37c9..7467d2b8fef7 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -146,15 +146,8 @@ public ServerResponse.BodyBuilder contentType(MediaType contentType) { } @Override - public ServerResponse.BodyBuilder eTag(String etag) { - Assert.notNull(etag, "etag must not be null"); - if (!etag.startsWith("\"") && !etag.startsWith("W/\"")) { - etag = "\"" + etag; - } - if (!etag.endsWith("\"")) { - etag = etag + "\""; - } - this.headers.setETag(etag); + public ServerResponse.BodyBuilder eTag(String tag) { + this.headers.setETag(tag); return this; } @@ -225,6 +218,11 @@ public Mono bodyValue(Object body) { return initBuilder(body, BodyInserters.fromValue(body)); } + @Override + public Mono bodyValue(T body, ParameterizedTypeReference bodyType) { + return initBuilder(body, BodyInserters.fromValue(body, bodyType)); + } + @Override public > Mono body(P publisher, Class elementClass) { return initBuilder(publisher, BodyInserters.fromPublisher(publisher, elementClass)); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/EntityResponse.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/EntityResponse.java index 8bbbb32320b1..e602252205be 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/EntityResponse.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/EntityResponse.java @@ -263,7 +263,7 @@ interface Builder { Builder cacheControl(CacheControl cacheControl); /** - * Configure one or more request header names (e.g. "Accept-Language") to + * Configure one or more request header names (for example, "Accept-Language") to * add to the "Vary" response header to inform clients that the response is * subject to content negotiation and variances based on the value of the * given request headers. The configured request header names are added only diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/PathResourceLookupFunction.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/PathResourceLookupFunction.java index 04a28b753dd9..138e39f0d964 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/PathResourceLookupFunction.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/PathResourceLookupFunction.java @@ -18,20 +18,14 @@ import java.io.IOException; import java.io.UncheckedIOException; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.util.function.Function; import reactor.core.publisher.Mono; -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.util.Assert; -import org.springframework.util.ResourceUtils; -import org.springframework.util.StringUtils; -import org.springframework.web.util.UriUtils; +import org.springframework.web.reactive.resource.ResourceHandlerUtils; import org.springframework.web.util.pattern.PathPattern; import org.springframework.web.util.pattern.PathPatternParser; @@ -50,12 +44,11 @@ class PathResourceLookupFunction implements Function apply(ServerRequest request) { PathContainer pathContainer = request.requestPath().pathWithinApplication(); @@ -64,21 +57,14 @@ public Mono apply(ServerRequest request) { } pathContainer = this.pattern.extractPathWithinPattern(pathContainer); - String path = processPath(pathContainer.value()); - if (!StringUtils.hasText(path) || isInvalidPath(path)) { - return Mono.empty(); - } - if (isInvalidEncodedInputPath(path)) { + String path = ResourceHandlerUtils.normalizeInputPath(pathContainer.value()); + if (ResourceHandlerUtils.shouldIgnoreInputPath(path)) { return Mono.empty(); } - if (!(this.location instanceof UrlResource)) { - path = UriUtils.decode(path, StandardCharsets.UTF_8); - } - try { - Resource resource = this.location.createRelative(path); - if (resource.isReadable() && isResourceUnderLocation(resource)) { + Resource resource = ResourceHandlerUtils.createRelativeResource(this.location, path); + if (resource.isReadable() && ResourceHandlerUtils.isResourceUnderLocation(this.location, resource)) { return Mono.just(resource); } else { @@ -90,169 +76,6 @@ public Mono apply(ServerRequest request) { } } - /** - * Process the given resource path. - *

              The default implementation replaces: - *

                - *
              • Backslash with forward slash. - *
              • Duplicate occurrences of slash with a single slash. - *
              • Any combination of leading slash and control characters (00-1F and 7F) - * with a single "/" or "". For example {@code " / // foo/bar"} - * becomes {@code "/foo/bar"}. - *
              - */ - protected String processPath(String path) { - path = StringUtils.replace(path, "\\", "/"); - path = cleanDuplicateSlashes(path); - path = cleanLeadingSlash(path); - return normalizePath(path); - } - - private String cleanDuplicateSlashes(String path) { - StringBuilder sb = null; - char prev = 0; - for (int i = 0; i < path.length(); i++) { - char curr = path.charAt(i); - try { - if (curr == '/' && prev == '/') { - if (sb == null) { - sb = new StringBuilder(path.substring(0, i)); - } - continue; - } - if (sb != null) { - sb.append(path.charAt(i)); - } - } - finally { - prev = curr; - } - } - return (sb != null ? sb.toString() : path); - } - - private String cleanLeadingSlash(String path) { - boolean slash = false; - for (int i = 0; i < path.length(); i++) { - if (path.charAt(i) == '/') { - slash = true; - } - else if (path.charAt(i) > ' ' && path.charAt(i) != 127) { - if (i == 0 || (i == 1 && slash)) { - return path; - } - return (slash ? "/" + path.substring(i) : path.substring(i)); - } - } - return (slash ? "/" : ""); - } - - private static String normalizePath(String path) { - String result = path; - result = decode(result); - if (result.contains("%")) { - result = decode(result); - } - if (!StringUtils.hasText(result)) { - return result; - } - if (result.contains("../")) { - return StringUtils.cleanPath(result); - } - return path; - } - - private static String decode(String path) { - try { - return UriUtils.decode(path, StandardCharsets.UTF_8); - } - catch (Exception ex) { - return ""; - } - } - - private boolean isInvalidPath(String path) { - if (path.contains("WEB-INF") || path.contains("META-INF")) { - return true; - } - if (path.contains(":/")) { - String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path); - if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) { - return true; - } - } - return path.contains("../"); - } - - /** - * Check whether the given path contains invalid escape sequences. - * @param path the path to validate - * @return {@code true} if the path is invalid, {@code false} otherwise - */ - private boolean isInvalidEncodedInputPath(String path) { - if (path.contains("%")) { - try { - // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars - String decodedPath = URLDecoder.decode(path, StandardCharsets.UTF_8); - if (isInvalidPath(decodedPath)) { - return true; - } - decodedPath = processPath(decodedPath); - if (isInvalidPath(decodedPath)) { - return true; - } - } - catch (IllegalArgumentException ex) { - // May not be possible to decode... - } - } - return false; - } - - private boolean isResourceUnderLocation(Resource resource) throws IOException { - if (resource.getClass() != this.location.getClass()) { - return false; - } - - String resourcePath; - String locationPath; - - if (resource instanceof UrlResource) { - resourcePath = resource.getURL().toExternalForm(); - locationPath = StringUtils.cleanPath(this.location.getURL().toString()); - } - else if (resource instanceof ClassPathResource classPathResource) { - resourcePath = classPathResource.getPath(); - locationPath = StringUtils.cleanPath(((ClassPathResource) this.location).getPath()); - } - else { - resourcePath = resource.getURL().getPath(); - locationPath = StringUtils.cleanPath(this.location.getURL().getPath()); - } - - if (locationPath.equals(resourcePath)) { - return true; - } - locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/"); - return (resourcePath.startsWith(locationPath) && !isInvalidEncodedResourcePath(resourcePath)); - } - - private boolean isInvalidEncodedResourcePath(String resourcePath) { - if (resourcePath.contains("%")) { - // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars... - try { - String decodedPath = URLDecoder.decode(resourcePath, StandardCharsets.UTF_8); - if (decodedPath.contains("../") || decodedPath.contains("..\\")) { - return true; - } - } - catch (IllegalArgumentException ex) { - // May not be possible to decode... - } - } - return false; - } - @Override public String toString() { return this.pattern + " -> " + this.location; diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java index 956ec7be189d..157a360aa7a8 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java @@ -329,28 +329,6 @@ private static PathPattern mergePatterns(@Nullable PathPattern oldPattern, PathP } } - private static Map mergeMaps(Map left, Map right) { - if (left.isEmpty()) { - if (right.isEmpty()) { - return Collections.emptyMap(); - } - else { - return right; - } - } - else { - if (right.isEmpty()) { - return left; - } - else { - Map result = CollectionUtils.newLinkedHashMap(left.size() + right.size()); - result.putAll(left); - result.putAll(right); - return result; - } - } - } - /** * Receives notifications from the logical structure of request predicates. @@ -640,7 +618,7 @@ protected Result testInternal(ServerRequest request) { private void modifyAttributes(Map attributes, ServerRequest request, Map variables) { - Map pathVariables = mergeMaps(request.pathVariables(), variables); + Map pathVariables = CollectionUtils.compositeMap(request.pathVariables(), variables); attributes.put(RouterFunctions.URI_TEMPLATE_VARIABLES_ATTRIBUTE, Collections.unmodifiableMap(pathVariables)); @@ -865,7 +843,7 @@ public PathExtensionPredicate(String extension) { @Override public boolean test(ServerRequest request) { String pathExtension = UriUtils.extractFileExtension(request.path()); - return this.extensionPredicate.test(pathExtension); + return (pathExtension != null && this.extensionPredicate.test(pathExtension)); } @Override @@ -1334,7 +1312,9 @@ private static class ExtendedAttributesServerRequestWrapper extends DelegatingSe public ExtendedAttributesServerRequestWrapper(ServerRequest delegate, Map newAttributes) { super(delegate); Assert.notNull(newAttributes, "NewAttributes must not be null"); - this.attributes = mergeMaps(delegate.attributes(), newAttributes); + Map oldAttributes = delegate.attributes(); + this.attributes = CollectionUtils.compositeMap(newAttributes, oldAttributes, newAttributes::put, + newAttributes::putAll); } @Override @@ -1383,12 +1363,21 @@ private static Map mergeAttributes(ServerRequest request, Map oldPathVariables = request.pathVariables(); + Map pathVariables; + if (oldPathVariables.isEmpty()) { + pathVariables = newPathVariables; + } + else { + pathVariables = CollectionUtils.compositeMap(oldPathVariables, newPathVariables); + } + PathPattern oldPathPattern = (PathPattern) request.attribute(RouterFunctions.MATCHING_PATTERN_ATTRIBUTE) .orElse(null); + PathPattern pathPattern = mergePatterns(oldPathPattern, newPathPattern); - Map result = new LinkedHashMap<>(2); - result.put(RouterFunctions.URI_TEMPLATE_VARIABLES_ATTRIBUTE, mergeMaps(oldPathVariables, newPathVariables)); - result.put(RouterFunctions.MATCHING_PATTERN_ATTRIBUTE, mergePatterns(oldPathPattern, newPathPattern)); + Map result = CollectionUtils.newLinkedHashMap(2); + result.put(RouterFunctions.URI_TEMPLATE_VARIABLES_ATTRIBUTE, pathVariables); + result.put(RouterFunctions.MATCHING_PATTERN_ATTRIBUTE, pathPattern); return result; } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctions.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctions.java index ef3502133280..d2b81a2278e5 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctions.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctions.java @@ -79,8 +79,8 @@ public abstract class RouterFunctions { RouterFunctions.class.getName() + ".uriTemplateVariables"; /** - * Name of the {@link ServerWebExchange#getAttributes() attribute} that - * contains the matching pattern, as a {@link org.springframework.web.util.pattern.PathPattern}. + * Name of the {@link ServerWebExchange#getAttributes() attribute} that contains + * the matching pattern, as an {@link org.springframework.web.util.pattern.PathPattern}. */ public static final String MATCHING_PATTERN_ATTRIBUTE = RouterFunctions.class.getName() + ".matchingPattern"; @@ -184,7 +184,7 @@ public static RouterFunction resource(RequestPredicate predicate * Route requests that match the given pattern to resources relative to the given root location. * For instance *
              -	 * Resource location = new FileSystemResource("public-resources/");
              +	 * Resource location = new FileUrlResource("public-resources/");
               	 * RouterFunction<ServerResponse> resources = RouterFunctions.resources("/resources/**", location);
                    * 
              * @param pattern the pattern to match @@ -201,7 +201,7 @@ public static RouterFunction resources(String pattern, Resource * Route requests that match the given pattern to resources relative to the given root location. * For instance *
              -	 * Resource location = new FileSystemResource("public-resources/");
              +	 * Resource location = new FileUrlResource("public-resources/");
               	 * RouterFunction<ServerResponse> resources = RouterFunctions.resources("/resources/**", location);
                    * 
              * @param pattern the pattern to match @@ -224,7 +224,7 @@ public static RouterFunction resources(String pattern, Resource *
               	 * Mono<Resource> defaultResource = Mono.just(new ClassPathResource("index.html"));
               	 * Function<ServerRequest, Mono<Resource>> lookupFunction =
              -	 *   RouterFunctions.resourceLookupFunction("/resources/**", new FileSystemResource("public-resources/"))
              +	 *   RouterFunctions.resourceLookupFunction("/resources/**", new FileUrlResource("public-resources/"))
               	 *     .andThen(resourceMono -> resourceMono.switchIfEmpty(defaultResource));
               	 * RouterFunction<ServerResponse> resources = RouterFunctions.resources(lookupFunction);
                    * 
              @@ -263,42 +263,43 @@ public static RouterFunction resources(FunctionThe returned handler can be adapted to run in + * Convert the given {@linkplain RouterFunction router function} into an + * {@link HttpHandler}, using the {@linkplain HandlerStrategies#builder() + * default strategies}. + *

              The returned handler can be adapted to run in the following environments. *

                *
              • Servlet environments using the - * {@link org.springframework.http.server.reactive.ServletHttpHandlerAdapter},
              • + * {@link org.springframework.http.server.reactive.ServletHttpHandlerAdapter} *
              • Reactor using the - * {@link org.springframework.http.server.reactive.ReactorHttpHandlerAdapter},
              • + * {@link org.springframework.http.server.reactive.ReactorHttpHandlerAdapter} *
              • Undertow using the - * {@link org.springframework.http.server.reactive.UndertowHttpHandlerAdapter}.
              • + * {@link org.springframework.http.server.reactive.UndertowHttpHandlerAdapter} *
              - *

              Note that {@code HttpWebHandlerAdapter} also implements {@link WebHandler}, allowing - * for additional filter and exception handler registration through + *

              Note that {@code HttpWebHandlerAdapter} also implements {@link WebHandler}, + * allowing for additional filter and exception handler registration through * {@link WebHttpHandlerBuilder}. * @param routerFunction the router function to convert - * @return an HTTP handler that handles HTTP request using the given router function + * @return an HTTP handler that handles HTTP requests using the given router function */ public static HttpHandler toHttpHandler(RouterFunction routerFunction) { return toHttpHandler(routerFunction, HandlerStrategies.withDefaults()); } /** - * Convert the given {@linkplain RouterFunction router function} into a {@link HttpHandler}, - * using the given strategies. - *

              The returned {@code HttpHandler} can be adapted to run in + * Convert the given {@linkplain RouterFunction router function} into an + * {@link HttpHandler}, using the given strategies. + *

              The returned handler can be adapted to run in the following environments. *

                *
              • Servlet environments using the - * {@link org.springframework.http.server.reactive.ServletHttpHandlerAdapter},
              • + * {@link org.springframework.http.server.reactive.ServletHttpHandlerAdapter} *
              • Reactor using the - * {@link org.springframework.http.server.reactive.ReactorHttpHandlerAdapter},
              • + * {@link org.springframework.http.server.reactive.ReactorHttpHandlerAdapter} *
              • Undertow using the - * {@link org.springframework.http.server.reactive.UndertowHttpHandlerAdapter}.
              • + * {@link org.springframework.http.server.reactive.UndertowHttpHandlerAdapter} *
              * @param routerFunction the router function to convert * @param strategies the strategies to use - * @return an HTTP handler that handles HTTP request using the given router function + * @return an HTTP handler that handles HTTP requests using the given router function */ public static HttpHandler toHttpHandler(RouterFunction routerFunction, HandlerStrategies strategies) { WebHandler webHandler = toWebHandler(routerFunction, strategies); @@ -760,7 +761,7 @@ public interface Builder { * Route requests that match the given pattern to resources relative to the given root location. * For instance *
              -		 * Resource location = new FileSystemResource("public-resources/");
              +		 * Resource location = new FileUrlResource("public-resources/");
               		 * RouterFunction<ServerResponse> resources = RouterFunctions.resources("/resources/**", location);
               	     * 
              * @param pattern the pattern to match @@ -774,7 +775,7 @@ public interface Builder { * Route requests that match the given pattern to resources relative to the given root location. * For instance *
              -		 * Resource location = new FileSystemResource("public-resources/");
              +		 * Resource location = new FileUrlResource("public-resources/");
               		 * RouterFunction<ServerResponse> resources = RouterFunctions.resources("/resources/**", location);
               	     * 
              * @param pattern the pattern to match @@ -1264,9 +1265,13 @@ public Mono> route(ServerRequest serverRequest) { return this.routerFunction.route(nestedRequest) .doOnNext(match -> { if (nestedRequest != serverRequest) { - serverRequest.attributes().clear(); - serverRequest.attributes() - .putAll(nestedRequest.attributes()); + // new attributes map from nestedRequest.attributes() can be composed of the old attributes, + // which means that clearing the old attributes will remove those values from new attributes as well + // so let's make a copy + Map newAttributes = new LinkedHashMap<>(nestedRequest.attributes()); + Map oldAttributes = serverRequest.attributes(); + oldAttributes.clear(); + oldAttributes.putAll(newAttributes); } }); } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java index dee71f14c4ed..af3ebeaa17d6 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java @@ -214,7 +214,7 @@ default Mono bind(Class bindType) { /** * Bind to this request and return an instance of the given type. * @param bindType the type of class to bind this request to - * @param dataBinderCustomizer used to customize the data binder, e.g. set + * @param dataBinderCustomizer used to customize the data binder, for example, set * (dis)allowed fields * @param the type to bind to * @return a mono containing either a constructed and bound instance of diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java index 76d3e252a1f0..910663ea9f7f 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java @@ -339,7 +339,7 @@ interface HeadersBuilder> { B cacheControl(CacheControl cacheControl); /** - * Configure one or more request header names (e.g. "Accept-Language") to + * Configure one or more request header names (for example, "Accept-Language") to * add to the "Vary" response header to inform clients that the response is * subject to content negotiation and variances based on the value of the * given request headers. The configured request header names are added only @@ -420,6 +420,20 @@ interface BodyBuilder extends HeadersBuilder { */ Mono bodyValue(Object body); + /** + * Set the body of the response to the given {@code Object} and return it. + * This is a shortcut for using a {@link #body(BodyInserter)} with a + * {@linkplain BodyInserters#fromValue value inserter}. + * @param body the body of the response + * @param bodyType the type of the body, used to capture the generic type + * @param the type of the body + * @return the built response + * @throws IllegalArgumentException if {@code body} is a + * {@link Publisher} or producer known to {@link ReactiveAdapterRegistry} + * @since 6.2 + */ + Mono bodyValue(T body, ParameterizedTypeReference bodyType); + /** * Set the body from the given {@code Publisher}. Shortcut for * {@link #body(BodyInserter)} with a diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/ServerResponseResultHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/ServerResponseResultHandler.java index 1eac334d3f3a..d70e2dde711e 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/ServerResponseResultHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/ServerResponseResultHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2025 the original author 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,19 +50,38 @@ public class ServerResponseResultHandler implements HandlerResultHandler, Initia /** * Configure HTTP message writers to serialize the request body with. - *

              By default this is set to {@link ServerCodecConfigurer}'s default writers. + *

              By default, this is set to {@link ServerCodecConfigurer}'s default writers. */ public void setMessageWriters(List> configurer) { this.messageWriters = configurer; } + /** + * Return the configured {@link HttpMessageWriter}'s. + * @since 6.2.3 + */ + public List> getMessageWriters() { + return this.messageWriters; + } + + /** + * Set the current view resolvers. + */ public void setViewResolvers(List viewResolvers) { this.viewResolvers = viewResolvers; } + /** + * Return the configured {@link ViewResolver}'s. + * @since 6.2.3 + */ + public List getViewResolvers() { + return this.viewResolvers; + } + /** * Set the order for this result handler relative to others. - *

              By default set to 0. It is generally safe to place it early in the + *

              By default, set to 0. It is generally safe to place it early in the * order as it looks for a concrete return type. */ public void setOrder(int order) { 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 de09dba809c9..643e9dda2a35 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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 76d267509802..610ad2853177 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 @@ -38,8 +38,8 @@ * Abstract base class for URL-mapped * {@link org.springframework.web.reactive.HandlerMapping} implementations. * - *

              Supports direct matches, e.g. a registered "/test" matches "/test", and - * various path pattern matches, e.g. a registered "/t*" pattern matches + *

              Supports direct matches, for example, a registered "/test" matches "/test", and + * various path pattern matches, for example, a registered "/t*" pattern matches * both "/test" and "/team", "/test/*" matches all paths under "/test", * "/test/**" matches all paths below "/test". For details, see the * {@link org.springframework.web.util.pattern.PathPattern} javadoc. @@ -119,8 +119,8 @@ public Mono getHandlerInternal(ServerWebExchange exchange) { /** * Look up a handler instance for the given URL lookup path. - *

              Supports direct matches, e.g. a registered "/test" matches "/test", - * and various path pattern matches, e.g. a registered "/t*" matches + *

              Supports direct matches, for example, a registered "/test" matches "/test", + * and various path pattern matches, for example, a registered "/t*" matches * both "/test" and "/team". For details, see the PathPattern class. * @param lookupPath the URL the handler is mapped to * @param exchange the current exchange 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 bf83751cd261..74c28a30f447 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 @@ -41,8 +41,8 @@ *

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

              Supports direct matches, e.g. a registered "/test" matches "/test", and - * various Ant-style pattern matches, e.g. a registered "/t*" pattern matches + *

              Supports direct matches, for example, a registered "/test" matches "/test", and + * various Ant-style pattern matches, for example, a registered "/t*" pattern matches * both "/test" and "/team", "/test/*" matches all paths under "/test", * "/test/**" matches all paths below "/test". For details, see the * {@link org.springframework.web.util.pattern.PathPattern} javadoc. diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/AbstractFileNameVersionStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/AbstractFileNameVersionStrategy.java index b3513f50a897..04e347b4def4 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/AbstractFileNameVersionStrategy.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/AbstractFileNameVersionStrategy.java @@ -27,7 +27,7 @@ /** * Abstract base class for filename suffix based {@link VersionStrategy} - * implementations, e.g. "static/myresource-version.js" + * implementations, for example, "static/myresource-version.js". * * @author Rossen Stoyanchev * @author Brian Clozel diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/AbstractPrefixVersionStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/AbstractPrefixVersionStrategy.java index a36bed152979..6542ad3042d2 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/AbstractPrefixVersionStrategy.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/AbstractPrefixVersionStrategy.java @@ -24,7 +24,7 @@ /** * Abstract base class for {@link VersionStrategy} implementations that insert - * a prefix into the URL path, e.g. "version/static/myresource.js". + * a prefix into the URL path, for example, "version/static/myresource.js". * * @author Rossen Stoyanchev * @author Brian Clozel diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ContentVersionStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ContentVersionStrategy.java index 92aef05cc475..c5b8d760b523 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ContentVersionStrategy.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ContentVersionStrategy.java @@ -28,7 +28,7 @@ /** * A {@code VersionStrategy} that calculates a Hex MD5 hash from the content - * of the resource and appends it to the file name, e.g. + * of the resource and appends it to the file name, for example, * {@code "styles/main-e36d2e05253c6c7085a91522ce43a0b4.css"}. * * @author Rossen Stoyanchev diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java index 0bb8f0398d46..e95c23b6822b 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java @@ -42,7 +42,7 @@ /** * A {@link ResourceTransformer} implementation that modifies links in a CSS - * file to match the public URL paths that should be exposed to clients (e.g. + * file to match the public URL paths that should be exposed to clients (for example, * with an MD5 content-based hash inserted in the URL). * *

              The implementation looks for links in CSS {@code @import} statements and @@ -164,7 +164,7 @@ protected interface LinkParser { */ protected abstract static class AbstractLinkParser implements LinkParser { - /** Return the keyword to use to search for links, e.g. "@import", "url(" */ + /** Return the keyword to use to search for links, for example, "@import", "url(". */ protected abstract String getKeyword(); @Override diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/EncodedResourceResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/EncodedResourceResolver.java index c4f5abae0847..8750d2419745 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/EncodedResourceResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/EncodedResourceResolver.java @@ -43,7 +43,7 @@ /** * Resolver that delegates to the chain, and if a resource is found, it then - * attempts to find an encoded (e.g. gzip, brotli) variant that is acceptable + * attempts to find an encoded (for example, gzip, brotli) variant that is acceptable * based on the "Accept-Encoding" request header. * *

              The list of supported {@link #setContentCodings(List) contentCodings} can diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/FixedVersionStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/FixedVersionStrategy.java index ad3daecbd1f9..3a4f0f13a127 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/FixedVersionStrategy.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/FixedVersionStrategy.java @@ -22,7 +22,7 @@ /** * A {@code VersionStrategy} that relies on a fixed version applied as a request - * path prefix, e.g. reduced SHA, version name, release date, etc. + * path prefix, for example, reduced SHA, version name, release date, etc. * *

              This is useful for example when {@link ContentVersionStrategy} cannot be * used such as when using JavaScript module loaders which are in charge of diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/LiteWebJarsResourceResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/LiteWebJarsResourceResolver.java new file mode 100644 index 000000000000..d396dbccc7f5 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/LiteWebJarsResourceResolver.java @@ -0,0 +1,116 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.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.resource; + +import java.util.List; + +import org.webjars.WebJarVersionLocator; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.Resource; +import org.springframework.lang.Nullable; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@code ResourceResolver} that delegates to the chain to locate a resource and then + * attempts to find a matching versioned resource contained in a WebJar JAR file. + * + *

              This allows WebJars.org users to write version agnostic paths in their templates, + * like {@code