From 0ba14c1adc092e910ce20376bc7842f6b53e877c Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 3 Aug 2026 14:03:07 +0200 Subject: [PATCH 1/2] Add release train automation. Closes spring-projects/spring-data-build#2891 --- .../actions/release-train-build/action.yml | 30 ++++++ .../release-train-build/deployment-spec.yml | 6 ++ .github/actions/release-train-test/action.yml | 30 ++++++ .github/workflow-generator.yml | 26 ++++++ .github/workflows/ci.yml | 7 +- .github/workflows/codeql.yml | 1 + .github/workflows/release-train-build.yml | 92 +++++++++++++++++++ .github/workflows/release-train-join.yml | 43 +++++++++ .github/workflows/release-train-leave.yml | 34 +++++++ .github/workflows/release-train-ready.yml | 35 +++++++ .github/workflows/release-train-retry.yml | 34 +++++++ .github/workflows/release-train-test.yml | 85 +++++++++++++++++ .github/workflows/snapshots.yml | 14 +-- 13 files changed, 429 insertions(+), 8 deletions(-) create mode 100644 .github/actions/release-train-build/action.yml create mode 100644 .github/actions/release-train-build/deployment-spec.yml create mode 100644 .github/actions/release-train-test/action.yml create mode 100644 .github/workflow-generator.yml create mode 100644 .github/workflows/release-train-build.yml create mode 100644 .github/workflows/release-train-join.yml create mode 100644 .github/workflows/release-train-leave.yml create mode 100644 .github/workflows/release-train-ready.yml create mode 100644 .github/workflows/release-train-retry.yml create mode 100644 .github/workflows/release-train-test.yml diff --git a/.github/actions/release-train-build/action.yml b/.github/actions/release-train-build/action.yml new file mode 100644 index 0000000000..e2577cfd14 --- /dev/null +++ b/.github/actions/release-train-build/action.yml @@ -0,0 +1,30 @@ +name: Build Release +runs: + using: composite + steps: + - name: Write settings.xml + if: ${{ env.RELEASE_TRAIN_SETTINGS_XML != '' }} + shell: bash + run: | + echo "::notice::Writing provided settings-xml input to release-train-settings.xml" + echo "${RELEASE_TRAIN_SETTINGS_XML}" > release-train-settings.xml + - name: Prepare Maven command + shell: bash + run: | + MAVEN_LOGGING_OPTS=() + if [[ "${RUNNER_DEBUG}" == "1" || "${ACTIONS_STEP_DEBUG}" == "true" ]]; then + echo "::notice::transfer-progress enabled" + else + MAVEN_LOGGING_OPTS+=(--no-transfer-progress) + fi + + echo "RELEASE_TRAIN_MVN=./mvnw --batch-mode ${MAVEN_LOGGING_OPTS[*]} --settings release-train-settings.xml clean deploy -DaltDeploymentRepository=release-train::file://$(pwd)/deployment-repository -DskipTests -Dgpg.skip" >> "${GITHUB_ENV}" + - name: Build Release + shell: bash + run: ${RELEASE_TRAIN_MVN} --activate-profiles releaseTrain,release,ci + - name: Distribution build + shell: bash + run: ${RELEASE_TRAIN_MVN} --activate-profiles releaseTrain,distribute + - name: Schema build + shell: bash + run: ${RELEASE_TRAIN_MVN} --activate-profiles releaseTrain,distribute-schema diff --git a/.github/actions/release-train-build/deployment-spec.yml b/.github/actions/release-train-build/deployment-spec.yml new file mode 100644 index 0000000000..9980de5e0a --- /dev/null +++ b/.github/actions/release-train-build/deployment-spec.yml @@ -0,0 +1,6 @@ +artifactory: + artifacts: + - pattern: "/**/spring-data-*-docs.zip" + properties: + zip.deployed: "false" + zip.type: "docs" diff --git a/.github/actions/release-train-test/action.yml b/.github/actions/release-train-test/action.yml new file mode 100644 index 0000000000..570d295bbc --- /dev/null +++ b/.github/actions/release-train-test/action.yml @@ -0,0 +1,30 @@ +name: Test Release +runs: + using: composite + steps: + - name: Write settings.xml + if: ${{ env.RELEASE_TRAIN_SETTINGS_XML != '' }} + shell: bash + run: | + echo "::notice::Writing provided settings-xml input to release-train-settings.xml" + echo "${RELEASE_TRAIN_SETTINGS_XML}" > release-train-settings.xml + - name: Prepare Maven command + shell: bash + run: | + MAVEN_LOGGING_OPTS=() + if [[ "${RUNNER_DEBUG}" == "1" || "${ACTIONS_STEP_DEBUG}" == "true" ]]; then + echo "::notice::transfer-progress enabled" + else + MAVEN_LOGGING_OPTS+=(--no-transfer-progress) + fi + + echo "RELEASE_TRAIN_MVN=./mvnw --batch-mode ${MAVEN_LOGGING_OPTS[*]} --settings release-train-settings.xml clean verify -Dgpg.skip" >> "${GITHUB_ENV}" + - name: Build Release + shell: bash + run: ${RELEASE_TRAIN_MVN} --activate-profiles releaseTrain,release,ci + - name: Distribution build + shell: bash + run: ${RELEASE_TRAIN_MVN} --activate-profiles releaseTrain,distribute + - name: Schema build + shell: bash + run: ${RELEASE_TRAIN_MVN} --activate-profiles releaseTrain,distribute-schema diff --git a/.github/workflow-generator.yml b/.github/workflow-generator.yml new file mode 100644 index 0000000000..03add55f22 --- /dev/null +++ b/.github/workflow-generator.yml @@ -0,0 +1,26 @@ +workflow: + generator: + project: + java: + versions: + primary: 25 + workflows: + release-train: + join: + watch: false + leave: + watch: false + ready: + watch: false + build: + env: + COMMERCIAL_REPO_USERNAME: secrets.COMMERCIAL_ARTIFACTORY_USERNAME + COMMERCIAL_REPO_PASSWORD: secrets.COMMERCIAL_ARTIFACTORY_PASSWORD + COMMERCIAL_RELEASE_REPO_URL: vars.COMMERCIAL_RELEASE_REPO_URL + RELEASE_TRAIN_SETTINGS_XML: vars.RELEASE_TRAIN_SETTINGS_XML + test: + env: + COMMERCIAL_REPO_USERNAME: secrets.COMMERCIAL_ARTIFACTORY_USERNAME + COMMERCIAL_REPO_PASSWORD: secrets.COMMERCIAL_ARTIFACTORY_PASSWORD + COMMERCIAL_RELEASE_REPO_URL: vars.COMMERCIAL_RELEASE_REPO_URL + RELEASE_TRAIN_SETTINGS_XML: vars.RELEASE_TRAIN_SETTINGS_XML diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b392bf4d4..d57569e721 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: matrix: java-version: [ base, main ] name: Build project - runs-on: ubuntu-latest + runs-on: ${{ vars.ACTION_RUNNER || 'ubuntu-latest' }} steps: - uses: actions/checkout@v6 - name: Setup Java and Maven @@ -23,5 +23,10 @@ jobs: develocity-access-key: '${{ secrets.DEVELOCITY_ACCESS_KEY }}' - name: Build uses: spring-projects/spring-data-build/actions/maven-build@main + env: + OSS_ARTIFACTORY_USR: '${{ secrets.ARTIFACTORY_USERNAME }}' + OSS_ARTIFACTORY_PSW: '${{ secrets.ARTIFACTORY_PASSWORD }}' + COMMERCIAL_ARTIFACTORY_USR: '${{ secrets.COMMERCIAL_ARTIFACTORY_USERNAME }}' + COMMERCIAL_ARTIFACTORY_PSW: '${{ secrets.COMMERCIAL_ARTIFACTORY_PASSWORD }}' with: settings-xml: '${{ vars.SETTINGS_XML }}' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 411d4a9338..a478c78ed3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -4,6 +4,7 @@ name: "CodeQL Advanced" on: push: + branches: [ main ] pull_request: workflow_dispatch: schedule: diff --git a/.github/workflows/release-train-build.yml b/.github/workflows/release-train-build.yml new file mode 100644 index 0000000000..dbe280fb66 --- /dev/null +++ b/.github/workflows/release-train-build.yml @@ -0,0 +1,92 @@ +# This file was auto-generated by github-actions-workflow-generator 0.0.6. Do not edit. +# To update it, modify .github/workflow-generator.yml as needed and re-run the generator. + +name: "Release Train – Build" +run-name: "${{ inputs.callback-ref }} – Build" +"on": + workflow_dispatch: + inputs: + callback: + description: "Repository to which a callback should be made upon completion" + required: true + type: "string" + callback-ref: + description: "Ref in the callback repository to which a callback should be made upon completion" + required: true + type: "string" + release-train-maven-repository-url: + description: "URL of a Maven repository to be used to resolve artifacts of projects earlier in the train" + required: true + type: "string" +permissions: + contents: "read" +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" +jobs: + build-release: + name: "Build Release" + runs-on: "ubuntu22-2-8" + steps: + - name: "Prevent Re-runs" + id: "prevent-re-runs" + run: |- + if [ "$GITHUB_RUN_ATTEMPT" -gt 1 ]; then + echo "Re-runs are prohibited. Use the 'Release Train – Retry' workflow to retry build failures" + exit 1 + fi + - name: "Set up Java" + id: "set-up-java" + uses: "actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95" # v5.6.0 + with: + distribution: "liberica" + java-version: "25" + - name: "Check Out Code" + id: "check-out-code" + uses: "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" # v7.0.0 + - name: "Build Release" + id: "build-release" + uses: "./.github/actions/release-train-build" + env: + COMMERCIAL_RELEASE_REPO_URL: "${{ vars.COMMERCIAL_RELEASE_REPO_URL }}" + COMMERCIAL_REPO_PASSWORD: "${{ secrets.COMMERCIAL_ARTIFACTORY_PASSWORD }}" + COMMERCIAL_REPO_USERNAME: "${{ secrets.COMMERCIAL_ARTIFACTORY_USERNAME }}" + RELEASE_TRAIN_MAVEN_REPOSITORY_PASSWORD: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_MAVEN_REPOSITORY_PASSWORD }}" + RELEASE_TRAIN_MAVEN_REPOSITORY_URL: "${{ inputs.release-train-maven-repository-url }}" + RELEASE_TRAIN_MAVEN_REPOSITORY_USERNAME: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_MAVEN_REPOSITORY_USERNAME }}" + RELEASE_TRAIN_SETTINGS_XML: "${{ vars.RELEASE_TRAIN_SETTINGS_XML }}" + - name: "Upload Deployment Repository" + id: "upload-deployment-repository" + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" # v7.0.1 + with: + name: "deployment-repository" + path: "deployment-repository/**" + - name: "Upload Deployment Spec" + id: "upload-deployment-spec" + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" # v7.0.1 + with: + archive: "false" + if-no-files-found: "ignore" + name: "deployment-spec" + path: ".github/actions/release-train-build/deployment-spec.yml" + - name: "Save Build System Caches" + id: "save-build-system-caches" + uses: "actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9" # v6.1.0 + with: + key: "release-train-${{ inputs.callback-ref }}-${{ github.ref_name }}" + path: "~/.m2/repository" + - name: "Send Callback" + id: "send-callback" + if: "${{ !cancelled() }}" + env: + GH_TOKEN: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_GITHUB_TOKEN }}" + run: |- + gh workflow run callback \ + --repo ${{ inputs.callback }} \ + --ref ${{ inputs.callback-ref }} \ + --field commit-hash=${{ steps.check-out-code.outputs.commit }} \ + --field deployment-repository-artifact-identifier=${{ steps.upload-deployment-repository.outputs.artifact-id }} \ + --field deployment-spec-artifact-identifier=${{ steps.upload-deployment-spec.outputs.artifact-id }} \ + --field release-branch=${{ github.ref_name }} \ + --field release-repository=${{ github.repository }} \ + --field result=${{ job.status == 'success' && 'built' || 'build-failed' }} \ + --field workflow-run-url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/.github/workflows/release-train-join.yml b/.github/workflows/release-train-join.yml new file mode 100644 index 0000000000..6b5f342770 --- /dev/null +++ b/.github/workflows/release-train-join.yml @@ -0,0 +1,43 @@ +# This file was auto-generated by github-actions-workflow-generator 0.0.6. Do not edit. +# To update it, modify .github/workflow-generator.yml as needed and re-run the generator. + +name: "Release Train – Join" +run-name: "${{ inputs.release-train }} – Join" +"on": + workflow_dispatch: + inputs: + deployment-destination: + description: "Destination to which the release should be deployed" + options: + - "Maven Central" + - "Spring Enterprise" + required: true + type: "choice" + release-train: + description: "Release train" + required: true + type: "string" + release-train-repository: + default: "spring-io/release-train" + description: "Release train repository" + required: true + type: "string" +permissions: + contents: "none" +jobs: + join-release-train: + name: "Join Release Train" + runs-on: "ubuntu-latest" + steps: + - name: "Join Release Train" + id: "join-release-train" + env: + GH_TOKEN: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_GITHUB_TOKEN }}" + run: |- + gh workflow run join \ + --repo ${{ inputs.release-train-repository }} \ + --ref ${{ inputs.release-train }} \ + --field commit-hash=${{ github.sha }} \ + --field deployment-destination=${{ inputs.deployment-destination == 'Maven Central' && 'maven-central' || 'spring-enterprise' }} \ + --field release-branch=${{ github.ref_name }} \ + --field release-repository=${{ github.repository }} diff --git a/.github/workflows/release-train-leave.yml b/.github/workflows/release-train-leave.yml new file mode 100644 index 0000000000..855fbe8ff9 --- /dev/null +++ b/.github/workflows/release-train-leave.yml @@ -0,0 +1,34 @@ +# This file was auto-generated by github-actions-workflow-generator 0.0.6. Do not edit. +# To update it, modify .github/workflow-generator.yml as needed and re-run the generator. + +name: "Release Train – Leave" +run-name: "${{ inputs.release-train }} – Leave" +"on": + workflow_dispatch: + inputs: + release-train: + description: "Release train" + required: true + type: "string" + release-train-repository: + default: "spring-io/release-train" + description: "Release train repository" + required: true + type: "string" +permissions: + contents: "none" +jobs: + leave: + name: "Leave" + runs-on: "ubuntu-latest" + steps: + - name: "Leave" + id: "leave" + env: + GH_TOKEN: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_GITHUB_TOKEN }}" + run: |- + gh workflow run leave \ + --repo ${{ inputs.release-train-repository }} \ + --ref ${{ inputs.release-train }} \ + --field release-branch=${{ github.ref_name }} \ + --field release-repository=${{ github.repository }} diff --git a/.github/workflows/release-train-ready.yml b/.github/workflows/release-train-ready.yml new file mode 100644 index 0000000000..631765d8ef --- /dev/null +++ b/.github/workflows/release-train-ready.yml @@ -0,0 +1,35 @@ +# This file was auto-generated by github-actions-workflow-generator 0.0.6. Do not edit. +# To update it, modify .github/workflow-generator.yml as needed and re-run the generator. + +name: "Release Train – Ready" +run-name: "${{ inputs.release-train }} – Ready" +"on": + workflow_dispatch: + inputs: + release-train: + description: "Release train" + required: true + type: "string" + release-train-repository: + default: "spring-io/release-train" + description: "Release train repository" + required: true + type: "string" +permissions: + contents: "none" +jobs: + ready: + name: "Ready" + runs-on: "ubuntu-latest" + steps: + - name: "Ready" + id: "ready" + env: + GH_TOKEN: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_GITHUB_TOKEN }}" + run: |- + gh workflow run ready \ + --repo ${{ inputs.release-train-repository }} \ + --ref ${{ inputs.release-train }} \ + --field commit-hash=${{ github.sha }} \ + --field release-branch=${{ github.ref_name }} \ + --field release-repository=${{ github.repository }} diff --git a/.github/workflows/release-train-retry.yml b/.github/workflows/release-train-retry.yml new file mode 100644 index 0000000000..539ccfd250 --- /dev/null +++ b/.github/workflows/release-train-retry.yml @@ -0,0 +1,34 @@ +# This file was auto-generated by github-actions-workflow-generator 0.0.6. Do not edit. +# To update it, modify .github/workflow-generator.yml as needed and re-run the generator. + +name: "Release Train – Retry" +run-name: "${{ inputs.release-train }} – Retry" +"on": + workflow_dispatch: + inputs: + release-train: + description: "Release train" + required: true + type: "string" + release-train-repository: + default: "spring-io/release-train" + description: "Release train repository" + required: true + type: "string" +permissions: + contents: "none" +jobs: + trigger-retry: + name: "Trigger Retry" + runs-on: "ubuntu-latest" + steps: + - name: "Trigger Retry" + id: "trigger-retry" + env: + GH_TOKEN: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_GITHUB_TOKEN }}" + run: |- + gh workflow run retry \ + --repo ${{ inputs.release-train-repository }} \ + --ref ${{ inputs.release-train }} \ + --field release-branch=${{ github.ref_name }} \ + --field release-repository=${{ github.repository }} diff --git a/.github/workflows/release-train-test.yml b/.github/workflows/release-train-test.yml new file mode 100644 index 0000000000..301404158f --- /dev/null +++ b/.github/workflows/release-train-test.yml @@ -0,0 +1,85 @@ +# This file was auto-generated by github-actions-workflow-generator 0.0.6. Do not edit. +# To update it, modify .github/workflow-generator.yml as needed and re-run the generator. + +name: "Release Train – Test" +run-name: "${{ inputs.callback-ref }} – Test" +"on": + workflow_dispatch: + inputs: + callback: + description: "Repository to which a callback should be made upon completion" + required: true + type: "string" + callback-ref: + description: "Ref in the callback repository to which a callback should be made upon completion" + required: true + type: "string" + release-train-maven-repository-url: + description: "URL of a Maven repository to be used to resolve artifacts of projects earlier in the train" + required: true + type: "string" +permissions: + contents: "read" +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" +jobs: + test-release: + name: "Test Release" + runs-on: "ubuntu22-2-8" + steps: + - name: "Prevent Re-runs" + id: "prevent-re-runs" + run: |- + if [ "$GITHUB_RUN_ATTEMPT" -gt 1 ]; then + echo "Re-runs are prohibited. Use the 'Release Train – Retry' workflow to retry test failures" + exit 1 + fi + - name: "Set up Java" + id: "set-up-java" + uses: "actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95" # v5.6.0 + with: + distribution: "liberica" + java-version: "25" + - name: "Check Out Code" + id: "check-out-code" + uses: "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" # v7.0.0 + - name: "Restore Build System Caches" + id: "restore-build-system-caches" + uses: "actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9" # v6.1.0 + with: + key: "release-train-${{ inputs.callback-ref }}-${{ github.ref_name }}" + path: "~/.m2/repository" + - name: "Test Release" + id: "test-release" + uses: "./.github/actions/release-train-test" + env: + COMMERCIAL_RELEASE_REPO_URL: "${{ vars.COMMERCIAL_RELEASE_REPO_URL }}" + COMMERCIAL_REPO_PASSWORD: "${{ secrets.COMMERCIAL_ARTIFACTORY_PASSWORD }}" + COMMERCIAL_REPO_USERNAME: "${{ secrets.COMMERCIAL_ARTIFACTORY_USERNAME }}" + RELEASE_TRAIN_MAVEN_REPOSITORY_PASSWORD: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_MAVEN_REPOSITORY_PASSWORD }}" + RELEASE_TRAIN_MAVEN_REPOSITORY_URL: "${{ inputs.release-train-maven-repository-url }}" + RELEASE_TRAIN_MAVEN_REPOSITORY_USERNAME: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_MAVEN_REPOSITORY_USERNAME }}" + RELEASE_TRAIN_SETTINGS_XML: "${{ vars.RELEASE_TRAIN_SETTINGS_XML }}" + - name: "Send Callback" + id: "send-callback" + if: "${{ !cancelled() }}" + env: + GH_TOKEN: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_GITHUB_TOKEN }}" + run: |- + gh workflow run callback \ + --repo ${{ inputs.callback }} \ + --ref ${{ inputs.callback-ref }} \ + --field commit-hash=${{ steps.check-out-code.outputs.commit }} \ + --field release-branch=${{ github.ref_name }} \ + --field release-repository=${{ github.repository }} \ + --field result=${{ job.status == 'success' && 'tested' || 'test-failed' }} \ + --field workflow-run-url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + - name: "Upload Build System Reports" + id: "upload-build-system-reports" + if: "${{ failure() }}" + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" # v7.0.1 + with: + name: "build-system-reports" + path: |- + **/target/surefire-reports + **/target/failsafe-reports diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index 33caae9a3c..6000a26e29 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -3,7 +3,7 @@ name: Snapshots on: workflow_dispatch: push: - branches: [ main, 'issue/**' ] + branches: [ main, 'issue/**', 'release/**' ] permissions: read-all @@ -11,7 +11,7 @@ jobs: build-snapshots: name: Build and deploy snapshots if: ${{ github.repository_owner == 'spring-projects' }} - runs-on: ubuntu-latest + runs-on: ${{ vars.ACTION_RUNNER || 'ubuntu-latest' }} steps: - uses: actions/checkout@v6 - name: Setup Java and Maven @@ -20,11 +20,11 @@ jobs: develocity-access-key: '${{ secrets.DEVELOCITY_ACCESS_KEY }}' - name: Deploy to Artifactory uses: spring-projects/spring-data-build/actions/maven-artifactory-deploy@main + env: + OSS_ARTIFACTORY_USR: '${{ secrets.ARTIFACTORY_USERNAME }}' + OSS_ARTIFACTORY_PSW: '${{ secrets.ARTIFACTORY_PASSWORD }}' + COMMERCIAL_ARTIFACTORY_USR: '${{ secrets.COMMERCIAL_ARTIFACTORY_USERNAME }}' + COMMERCIAL_ARTIFACTORY_PSW: '${{ secrets.COMMERCIAL_ARTIFACTORY_PASSWORD }}' with: build-name: 'spring-data-commons' - username: '${{ secrets.ARTIFACTORY_USERNAME }}' - password: '${{ secrets.ARTIFACTORY_PASSWORD }}' - context-url: '${{ vars.ARTIFACTORY_CONTEXT_URL }}' - repository: '${{ vars.ARTIFACTORY_REPOSITORY }}' - project: '${{ vars.ARTIFACTORY_PROJECT }}' settings-xml: '${{ vars.SETTINGS_XML }}' From fd8f292435f7e7f50af0b154c430b726197fc3ab Mon Sep 17 00:00:00 2001 From: adrien caubel Date: Sat, 8 Aug 2026 10:33:00 +0200 Subject: [PATCH 2/2] Provide a SlicedModel DTO to render stable JSON representations of Slice. Related tickets #3523 Signed-off-by: adrien caubel --- src/main/antora/modules/ROOT/pages/aot.adoc | 2 +- .../repositories/core-extensions-web.adoc | 56 ++++++++- .../springframework/data/web/SlicedModel.java | 90 +++++++++++++ .../data/web/aot/WebRuntimeHints.java | 27 +++- .../SpringDataJackson3Configuration.java | 34 +++-- .../SpringDataJacksonConfiguration.java | 34 +++-- ...mplJsonJackson2SerializationUnitTests.java | 118 ++++++++++++++++++ .../SliceImplJsonSerializationUnitTests.java | 118 ++++++++++++++++++ ...eSpringDataWebSupportIntegrationTests.java | 28 +++++ .../data/web/config/PageSampleController.java | 8 ++ 10 files changed, 493 insertions(+), 22 deletions(-) create mode 100644 src/main/java/org/springframework/data/web/SlicedModel.java create mode 100644 src/test/java/org/springframework/data/web/SliceImplJsonJackson2SerializationUnitTests.java create mode 100644 src/test/java/org/springframework/data/web/SliceImplJsonSerializationUnitTests.java diff --git a/src/main/antora/modules/ROOT/pages/aot.adoc b/src/main/antora/modules/ROOT/pages/aot.adoc index fbc059ab03..e7958497d4 100644 --- a/src/main/antora/modules/ROOT/pages/aot.adoc +++ b/src/main/antora/modules/ROOT/pages/aot.adoc @@ -157,5 +157,5 @@ These are in particular hints for: ** Repository fragments ** Querydsl `Q` classes ** Kotlin Coroutine support -* Web support (Jackson Hints for `PagedModel`) +* Web support (Jackson Hints for `PagedModel` and `SlicedModel`) diff --git a/src/main/antora/modules/ROOT/pages/repositories/core-extensions-web.adoc b/src/main/antora/modules/ROOT/pages/repositories/core-extensions-web.adoc index 246947709a..914ed7b281 100644 --- a/src/main/antora/modules/ROOT/pages/repositories/core-extensions-web.adoc +++ b/src/main/antora/modules/ROOT/pages/repositories/core-extensions-web.adoc @@ -195,10 +195,55 @@ This will result in a JSON structure looking like this: Note how the document contains a `page` field exposing the essential pagination metadata. +[[core.web.page.sliced-model]] +=== Using Spring Data's `SlicedModel` + +`Slice` instances have the same rendering-stability problem as `Page` instances, and `org.springframework.data.web.SlicedModel` is their equivalent DTO. +It is used in exactly the same way: + +[source,java] +---- +import org.springframework.data.web.SlicedModel; + +@Controller +class MyController { + + private final MyRepository repository; + + // Constructor omitted + + @GetMapping("/slice") + SlicedModel slice(Pageable pageable) { + return new SlicedModel<>(repository.findAllBy(pageable)); // <1> + } +} +---- + +<1> Wraps the `Slice` instance into a `SlicedModel`. + +This will result in a JSON structure looking like this: + +[source,javascript] +---- +{ + "content" : [ + … // Slice content rendered here + ], + "page" : { + "size" : 20, + "number" : 0, + "hasNext" : true + } +} +---- + +The metadata is exposed under the same `page` field as for `PagedModel`. +As a `Slice` does not know the total number of elements, `totalElements` and `totalPages` are replaced by `hasNext`, which tells the client whether a further slice can be requested. + [[core.web.page.config]] -=== Globally enabling simplified `Page` rendering +=== Globally enabling simplified `Page` and `Slice` rendering -If you don't want to change all your existing controllers to add the mapping step to return `PagedModel` instead of `Page` you can enable the automatic translation of `PageImpl` instances into `PagedModel` by tweaking `@EnableSpringDataWebSupport` as follows: +If you don't want to change all your existing controllers to add the mapping step to return `PagedModel` instead of `Page` (or `SlicedModel` instead of `Slice`) you can enable the automatic translation of `PageImpl` instances into `PagedModel` and of `SliceImpl` instances into `SlicedModel` by tweaking `@EnableSpringDataWebSupport` as follows: [source,java] ---- @@ -206,7 +251,7 @@ If you don't want to change all your existing controllers to add the mapping ste class MyConfiguration { } ---- -This will allow your controller to still return `Page` instances and they will automatically be rendered into the simplified representation: +This will allow your controller to still return `Page` and `Slice` instances and they will automatically be rendered into the simplified representation: [source,java] ---- @@ -221,6 +266,11 @@ class MyController { Page page(Pageable pageable) { return repository.findAll(pageable); } + + @GetMapping("/slice") + Slice slice(Pageable pageable) { + return repository.findAllBy(pageable); + } } ---- diff --git a/src/main/java/org/springframework/data/web/SlicedModel.java b/src/main/java/org/springframework/data/web/SlicedModel.java new file mode 100644 index 0000000000..77b6fb0a69 --- /dev/null +++ b/src/main/java/org/springframework/data/web/SlicedModel.java @@ -0,0 +1,90 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.web; + +import java.util.List; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.domain.Slice; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * DTO to build stable JSON representations of a Spring Data {@link Slice}. It can either be selectively used in + * controller methods by calling {@code new SlicedModel<>(slice)} or generally activated as representation model for + * {@link org.springframework.data.domain.SliceImpl} instances by setting + * {@link org.springframework.data.web.config.EnableSpringDataWebSupport}'s {@code pageSerializationMode} to + * {@link org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode#VIA_DTO}. + * + * @author Adrien Caubel + * @since 4.2 + */ +public class SlicedModel { + + private final Slice slice; + + /** + * Creates a new {@link SlicedModel} for the given {@link Slice}. + * + * @param slice must not be {@literal null}. + */ + public SlicedModel(Slice slice) { + + Assert.notNull(slice, "Slice must not be null"); + + this.slice = slice; + } + + @JsonProperty + public List getContent() { + return slice.getContent(); + } + + @JsonProperty("page") + public SliceMetadata getMetadata() { + return new SliceMetadata(slice.getSize(), slice.getNumber(), slice.hasNext()); + } + + @Override + public boolean equals(@Nullable Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof SlicedModel that)) { + return false; + } + + return Objects.equals(this.slice, that.slice); + } + + @Override + public int hashCode() { + return Objects.hash(slice); + } + + public record SliceMetadata(long size, long number, boolean hasNext) { + + public SliceMetadata { + Assert.isTrue(size > -1, "Size must not be negative!"); + Assert.isTrue(number > -1, "Number must not be negative!"); + } + } +} diff --git a/src/main/java/org/springframework/data/web/aot/WebRuntimeHints.java b/src/main/java/org/springframework/data/web/aot/WebRuntimeHints.java index a9ab93ba2d..471db37e89 100644 --- a/src/main/java/org/springframework/data/web/aot/WebRuntimeHints.java +++ b/src/main/java/org/springframework/data/web/aot/WebRuntimeHints.java @@ -22,6 +22,7 @@ import org.springframework.aot.hint.RuntimeHintsRegistrar; import org.springframework.aot.hint.TypeReference; import org.springframework.data.web.PagedModel; +import org.springframework.data.web.SlicedModel; import org.springframework.data.web.config.EnableSpringDataWebSupport; import org.springframework.data.web.config.SpringDataJackson3Configuration; import org.springframework.data.web.config.SpringDataJacksonConfiguration.PageModule; @@ -32,6 +33,7 @@ * * @author Christoph Strobl * @author Mark Paluch + * @author Adrien Caubel * @since 3.2.3 */ class WebRuntimeHints implements RuntimeHintsRegistrar { @@ -57,6 +59,13 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) hints.reflection().registerType(PagedModel.PageMetadata.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS); + // Slice Model for Jackson Rendering + hints.reflection().registerType(org.springframework.data.web.SlicedModel.class, + MemberCategory.INVOKE_PUBLIC_METHODS); + + hints.reflection().registerType(SlicedModel.SliceMetadata.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.INVOKE_PUBLIC_METHODS); + hints.reflection().registerType(TypeReference.of("org.springframework.data.domain.Unpaged")); if (JACKSON2_PRESENT) { @@ -80,8 +89,15 @@ private static void contributeJackson2Hints(RuntimeHints hints) { hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS); hint.onReachableType(PageModule.class); }); + hints.reflection().registerType( + TypeReference + .of("org.springframework.data.web.config.SpringDataJacksonConfiguration$PageModule$SlicedModelConverter"), + hint -> { + hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS); + hint.onReachableType(PageModule.class); + }); hints.reflection().registerType(TypeReference.of( - "org.springframework.data.web.config.SpringDataJacksonConfiguration$PageModule$PlainPageSerializationWarning"), + "org.springframework.data.web.config.SpringDataJacksonConfiguration$PageModule$WarningLoggingModifier"), hint -> { hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS); hint.onReachableType(PageModule.class); @@ -98,8 +114,15 @@ private static void contributeJackson3Hints(RuntimeHints hints) { hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS); hint.onReachableType(SpringDataJackson3Configuration.PageModule.class); }); + hints.reflection().registerType( + TypeReference + .of("org.springframework.data.web.config.SpringDataJackson3Configuration$PageModule$SlicedModelConverter"), + hint -> { + hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS); + hint.onReachableType(SpringDataJackson3Configuration.PageModule.class); + }); hints.reflection().registerType(TypeReference.of( - "org.springframework.data.web.config.SpringDataJackson3Configuration$PageModule$PlainPageSerializationWarning"), + "org.springframework.data.web.config.SpringDataJackson3Configuration$PageModule$WarningLoggingModifier"), hint -> { hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS); hint.onReachableType(SpringDataJackson3Configuration.PageModule.class); diff --git a/src/main/java/org/springframework/data/web/config/SpringDataJackson3Configuration.java b/src/main/java/org/springframework/data/web/config/SpringDataJackson3Configuration.java index 618abd8884..2c10f75085 100644 --- a/src/main/java/org/springframework/data/web/config/SpringDataJackson3Configuration.java +++ b/src/main/java/org/springframework/data/web/config/SpringDataJackson3Configuration.java @@ -33,8 +33,11 @@ import org.springframework.context.annotation.Bean; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.data.geo.GeoJacksonModule; import org.springframework.data.web.PagedModel; +import org.springframework.data.web.SlicedModel; import org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode; import org.springframework.util.ClassUtils; @@ -43,6 +46,7 @@ * * @author Oliver Gierke * @author Mark Paluch + * @author Adrien Caubel * @since 4.0 */ public class SpringDataJackson3Configuration implements SpringDataJackson3Modules { @@ -62,13 +66,14 @@ public PageModule jackson3pageModule() { } /** - * A Jackson module customizing the serialization of {@link PageImpl} instances depending on the + * A Jackson module customizing the serialization of {@link PageImpl} and {@link SliceImpl} instances depending on the * {@link SpringDataWebSettings} handed into the instance. In case of {@link PageSerializationMode#DIRECT} being * configured, a no-op {@link StdConverter} is registered to issue a one-time warning about the mode being used (as - * it's not recommended). {@link PageSerializationMode#VIA_DTO} would register a converter wrapping {@link PageImpl} - * instances into {@link PagedModel}. + * it's not recommended). {@link PageSerializationMode#VIA_DTO} would register converters wrapping {@link PageImpl} + * instances into {@link PagedModel} and {@link SliceImpl} instances into {@link SlicedModel}. * * @author Oliver Drotbohm + * @author Adrien Caubel */ public static class PageModule extends SimpleModule { @@ -95,6 +100,7 @@ public PageModule(@Nullable SpringDataWebSettings settings) { } else { setMixInAnnotation(PageImpl.class, WrappingMixing.class); + setMixInAnnotation(SliceImpl.class, SliceWrappingMixin.class); } } @@ -127,8 +133,20 @@ static class PageModelConverter extends StdConverter, PagedModel> { } } + @JsonSerialize(converter = SlicedModelConverter.class) + abstract static class SliceWrappingMixin {} + + static class SlicedModelConverter extends StdConverter, SlicedModel> { + + @Override + public @Nullable SlicedModel convert(@Nullable Slice value) { + return value == null ? null : new SlicedModel<>(value); + } + } + /** - * A {@link ValueSerializerModifier} that logs a warning message if an instance of {@link Page} will be rendered. + * A {@link ValueSerializerModifier} that logs a warning message if an instance of {@link Slice} (which includes + * {@link Page}) will be rendered. * * @author Oliver Drotbohm */ @@ -136,9 +154,9 @@ static class WarningLoggingModifier extends ValueSerializerModifier { private static final Logger LOGGER = LoggerFactory.getLogger(WarningLoggingModifier.class); private static final String MESSAGE = """ - Serializing PageImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure! - For a stable JSON structure, please use Spring Data's PagedModel (globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)) - or Spring HATEOAS and Spring Data's PagedResourcesAssembler as documented in https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables. + Serializing PageImpl and SliceImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure! + For a stable JSON structure, please use Spring Data's PagedModel or SlicedModel (globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)) + or Spring HATEOAS and Spring Data's PagedResourcesAssembler or SlicedResourcesAssembler as documented in https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables. """; private static final @Serial long serialVersionUID = 954857444010009875L; @@ -149,7 +167,7 @@ static class WarningLoggingModifier extends ValueSerializerModifier { public List changeProperties(tools.jackson.databind.SerializationConfig config, tools.jackson.databind.BeanDescription.Supplier beanDesc, List beanProperties) { - if (Page.class.isAssignableFrom(beanDesc.getBeanClass()) && !warningRendered) { + if (Slice.class.isAssignableFrom(beanDesc.getBeanClass()) && !warningRendered) { this.warningRendered = true; LOGGER.warn(MESSAGE); diff --git a/src/main/java/org/springframework/data/web/config/SpringDataJacksonConfiguration.java b/src/main/java/org/springframework/data/web/config/SpringDataJacksonConfiguration.java index 1f8ea782bb..2b77ba9d5f 100644 --- a/src/main/java/org/springframework/data/web/config/SpringDataJacksonConfiguration.java +++ b/src/main/java/org/springframework/data/web/config/SpringDataJacksonConfiguration.java @@ -27,8 +27,11 @@ import org.springframework.context.annotation.Bean; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.data.geo.GeoModule; import org.springframework.data.web.PagedModel; +import org.springframework.data.web.SlicedModel; import org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode; import org.springframework.util.ClassUtils; @@ -46,6 +49,7 @@ * * @author Oliver Gierke * @author Mark Paluch + * @author Adrien Caubel * @deprecated since 4.0, in favor of {@link SpringDataJackson3Configuration} which uses Jackson 3. */ @SuppressWarnings("removal") @@ -66,13 +70,14 @@ public PageModule pageModule() { } /** - * A Jackson module customizing the serialization of {@link PageImpl} instances depending on the + * A Jackson module customizing the serialization of {@link PageImpl} and {@link SliceImpl} instances depending on the * {@link SpringDataWebSettings} handed into the instance. In case of {@link PageSerializationMode#DIRECT} being * configured, a no-op {@link StdConverter} is registered to issue a one-time warning about the mode being used (as - * it's not recommended). {@link PageSerializationMode#VIA_DTO} would register a converter wrapping {@link PageImpl} - * instances into {@link PagedModel}. + * it's not recommended). {@link PageSerializationMode#VIA_DTO} would register converters wrapping {@link PageImpl} + * instances into {@link PagedModel} and {@link SliceImpl} instances into {@link SlicedModel}. * * @author Oliver Drotbohm + * @author Adrien Caubel */ public static class PageModule extends SimpleModule { @@ -99,6 +104,7 @@ public PageModule(@Nullable SpringDataWebSettings settings) { } else { setMixInAnnotation(PageImpl.class, WrappingMixing.class); + setMixInAnnotation(SliceImpl.class, SliceWrappingMixin.class); } } @@ -133,8 +139,20 @@ static class PageModelConverter extends StdConverter, PagedModel> { } } + @JsonSerialize(converter = SlicedModelConverter.class) + abstract static class SliceWrappingMixin {} + + static class SlicedModelConverter extends StdConverter, SlicedModel> { + + @Override + public @Nullable SlicedModel convert(@Nullable Slice value) { + return value == null ? null : new SlicedModel<>(value); + } + } + /** - * A {@link BeanSerializerModifier} that logs a warning message if an instance of {@link Page} will be rendered. + * A {@link BeanSerializerModifier} that logs a warning message if an instance of {@link Slice} (which includes + * {@link Page}) will be rendered. * * @author Oliver Drotbohm */ @@ -142,9 +160,9 @@ static class WarningLoggingModifier extends BeanSerializerModifier { private static final Logger LOGGER = LoggerFactory.getLogger(WarningLoggingModifier.class); private static final String MESSAGE = """ - Serializing PageImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure! - For a stable JSON structure, please use Spring Data's PagedModel (globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)) - or Spring HATEOAS and Spring Data's PagedResourcesAssembler as documented in https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables. + Serializing PageImpl and SliceImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure! + For a stable JSON structure, please use Spring Data's PagedModel or SlicedModel (globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)) + or Spring HATEOAS and Spring Data's PagedResourcesAssembler or SlicedResourcesAssembler as documented in https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables. """; private static final @Serial long serialVersionUID = 954857444010009875L; @@ -155,7 +173,7 @@ static class WarningLoggingModifier extends BeanSerializerModifier { public List changeProperties(SerializationConfig config, BeanDescription beanDesc, List beanProperties) { - if (Page.class.isAssignableFrom(beanDesc.getBeanClass()) && !warningRendered) { + if (Slice.class.isAssignableFrom(beanDesc.getBeanClass()) && !warningRendered) { this.warningRendered = true; LOGGER.warn(MESSAGE); diff --git a/src/test/java/org/springframework/data/web/SliceImplJsonJackson2SerializationUnitTests.java b/src/test/java/org/springframework/data/web/SliceImplJsonJackson2SerializationUnitTests.java new file mode 100644 index 0000000000..9db2898a8d --- /dev/null +++ b/src/test/java/org/springframework/data/web/SliceImplJsonJackson2SerializationUnitTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.web; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode; +import org.springframework.data.web.config.SpringDataJacksonConfiguration; +import org.springframework.data.web.config.SpringDataWebSettings; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; + +/** + * Unit tests for SliceImpl serialization using Jackson 2. + * + * @author Adrien Caubel + */ +@SuppressWarnings("removal") +class SliceImplJsonJackson2SerializationUnitTests { + + private static final Slice SLICE = new SliceImpl<>(List.of("first", "second"), PageRequest.of(1, 2), true); + + @Test // GH-3516 + void serializesSliceImplAsJson() throws JsonProcessingException { + + String result = write(PageSerializationMode.DIRECT, SLICE); + + assertThat(JsonPath. read(result, "$.pageable")).isNotNull(); + assertThat(JsonPath. read(result, "$.first")).isFalse(); + } + + @Test // GH-3516 + void serializesSliceImplAsSlicedModel() throws JsonProcessingException { + + String result = write(PageSerializationMode.VIA_DTO, SLICE); + + assertThat(JsonPath.> read(result, "$.content")).containsExactly("first", "second"); + assertThat(JsonPath. read(result, "$.page.size")).isEqualTo(2); + assertThat(JsonPath. read(result, "$.page.number")).isEqualTo(1); + assertThat(JsonPath. read(result, "$.page.hasNext")).isTrue(); + } + + @Test // GH-3516 + void doesNotRenderTotalsForSlice() throws JsonProcessingException { + + String result = write(PageSerializationMode.VIA_DTO, SLICE); + + assertThat(result).doesNotContain("totalElements", "totalPages"); + } + + @Test // GH-3516 + void serializesCustomSliceAsSlicedModel() throws JsonProcessingException { + + String result = write(PageSerializationMode.VIA_DTO, new Extension<>("header")); + + assertThat(JsonPath. read(result, "$.page")).isNotNull(); + assertThat(result).doesNotContain("header"); + } + + @Test // GH-3516 + void stillSerializesPageImplAsPagedModel() throws JsonProcessingException { + + String result = write(PageSerializationMode.VIA_DTO, new PageImpl<>(List.of("a"), PageRequest.of(0, 2), 3)); + + assertThat(JsonPath. read(result, "$.page.totalElements")).isEqualTo(3); + assertThat(JsonPath. read(result, "$.page.totalPages")).isEqualTo(2); + } + + private static String write(PageSerializationMode mode, Slice slice) throws JsonProcessingException { + + SpringDataWebSettings settings = new SpringDataWebSettings(mode); + + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new SpringDataJacksonConfiguration.PageModule(settings)); + + return mapper.writeValueAsString(slice); + } + + static class Extension extends SliceImpl { + + private final Object header; + + public Extension(Object header) { + + super(Collections.emptyList()); + + this.header = header; + } + + public Object getHeader() { + return header; + } + } +} diff --git a/src/test/java/org/springframework/data/web/SliceImplJsonSerializationUnitTests.java b/src/test/java/org/springframework/data/web/SliceImplJsonSerializationUnitTests.java new file mode 100644 index 0000000000..28362d822c --- /dev/null +++ b/src/test/java/org/springframework/data/web/SliceImplJsonSerializationUnitTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.web; + +import static org.assertj.core.api.Assertions.*; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode; +import org.springframework.data.web.config.SpringDataJackson3Configuration; +import org.springframework.data.web.config.SpringDataWebSettings; + +import com.jayway.jsonpath.JsonPath; + +/** + * Unit tests for SliceImpl serialization. + * + * @author Adrien Caubel + */ +class SliceImplJsonSerializationUnitTests { + + private static final Slice SLICE = new SliceImpl<>(List.of("first", "second"), PageRequest.of(1, 2), true); + + @Test // GH-3516 + void serializesSliceImplAsJson() { + + String result = write(PageSerializationMode.DIRECT, SLICE); + + assertThat(JsonPath. read(result, "$.pageable")).isNotNull(); + assertThat(JsonPath. read(result, "$.first")).isFalse(); + } + + @Test // GH-3516 + void serializesSliceImplAsSlicedModel() { + + String result = write(PageSerializationMode.VIA_DTO, SLICE); + + assertThat(JsonPath.> read(result, "$.content")).containsExactly("first", "second"); + assertThat(JsonPath. read(result, "$.page.size")).isEqualTo(2); + assertThat(JsonPath. read(result, "$.page.number")).isEqualTo(1); + assertThat(JsonPath. read(result, "$.page.hasNext")).isTrue(); + } + + @Test // GH-3516 + void doesNotRenderTotalsForSlice() { + + String result = write(PageSerializationMode.VIA_DTO, SLICE); + + assertThat(result).doesNotContain("totalElements", "totalPages"); + } + + @Test // GH-3516 + void serializesCustomSliceAsSlicedModel() { + + String result = write(PageSerializationMode.VIA_DTO, new Extension<>("header")); + + assertThat(JsonPath. read(result, "$.page")).isNotNull(); + assertThat(result).doesNotContain("header"); + } + + @Test // GH-3516 + void stillSerializesPageImplAsPagedModel() { + + String result = write(PageSerializationMode.VIA_DTO, new PageImpl<>(List.of("a"), PageRequest.of(0, 2), 3)); + + assertThat(JsonPath. read(result, "$.page.totalElements")).isEqualTo(3); + assertThat(JsonPath. read(result, "$.page.totalPages")).isEqualTo(2); + } + + private static String write(PageSerializationMode mode, Slice slice) { + + SpringDataWebSettings settings = new SpringDataWebSettings(mode); + + ObjectMapper mapper = JsonMapper.builder().addModule(new SpringDataJackson3Configuration.PageModule(settings)) + .build(); + + return mapper.writeValueAsString(slice); + } + + static class Extension extends SliceImpl { + + private final Object header; + + public Extension(Object header) { + + super(Collections.emptyList()); + + this.header = header; + } + + public Object getHeader() { + return header; + } + } +} diff --git a/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java b/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java index 6359a1d3d5..aa7b1ab944 100755 --- a/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java +++ b/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java @@ -64,6 +64,7 @@ * @author Vedran Pavic * @author Yanming Zhou * @author Christoph Strobl + * @author Adrien Caubel */ class EnableSpringDataWebSupportIntegrationTests { @@ -333,6 +334,33 @@ void usesViaDtoPageSerializationMode() throws Exception { .andExpect(jsonPath("$.page").exists()); } + @Test // GH-3516 + void usesDirectSliceSerializationMode() throws Exception { + + var context = WebTestUtils.createApplicationContext(PageSampleConfigWithDirect.class); + + var mvc = MockMvcBuilders.webAppContextSetup(context).build(); + + mvc.perform(post("/slice"))// + .andExpect(status().isOk()) // + .andExpect(jsonPath("$.pageable").exists()); + } + + @Test // GH-3516 + void usesViaDtoSliceSerializationMode() throws Exception { + + var context = WebTestUtils.createApplicationContext(PageSampleConfigWithViaDto.class); + + var mvc = MockMvcBuilders.webAppContextSetup(context).build(); + + mvc.perform(post("/slice")) // + .andExpect(status().isOk()) // + .andExpect(jsonPath("$.content").isArray()) // + .andExpect(jsonPath("$.page.size").value(10)) // + .andExpect(jsonPath("$.page.number").value(0)) // + .andExpect(jsonPath("$.page.hasNext").value(true)); + } + private static void assertResolversRegistered(ApplicationContext context, Class... resolverTypes) { var adapter = context.getBean(RequestMappingHandlerAdapter.class); diff --git a/src/test/java/org/springframework/data/web/config/PageSampleController.java b/src/test/java/org/springframework/data/web/config/PageSampleController.java index d40cc0ff6a..be0f2023b2 100755 --- a/src/test/java/org/springframework/data/web/config/PageSampleController.java +++ b/src/test/java/org/springframework/data/web/config/PageSampleController.java @@ -18,6 +18,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -25,6 +27,7 @@ /** * @author Yanming Zhou + * @author Adrien Caubel */ @RestController class PageSampleController { @@ -33,4 +36,9 @@ class PageSampleController { Page page() { return new PageImpl<>(List.of("a", "b", "c"), Pageable.ofSize(10).withPage(0), 3); } + + @RequestMapping("/slice") + Slice slice() { + return new SliceImpl<>(List.of("a", "b", "c"), Pageable.ofSize(10).withPage(0), true); + } }